Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 29 additions & 47 deletions crates/jp_conversation/src/compat.rs
Original file line number Diff line number Diff line change
@@ -1,70 +1,52 @@
//! Backward-compatible deserialization for config deltas.
//! Backward-compatible deserialization for [`PartialAppConfig`].
//!
//! When the `AppConfig` schema evolves (fields added, removed, or renamed), old
//! conversation streams may contain `ConfigDelta` events referencing fields
//! that no longer exist. The standard serde `deny_unknown_fields` on
//! `Partial*Config` types causes deserialization to fail entirely.
//! When the [`AppConfig`] schema evolves (fields added, removed, or renamed),
//! old conversation data may reference fields that no longer exist. The
//! standard serde `deny_unknown_fields` on `Partial*Config` types causes
//! deserialization to fail entirely.
//!
//! This module provides schema-aware stripping: before deserializing a config
//! delta, we walk the JSON value alongside the current `AppConfig` schema and
//! remove any keys that don't exist in the schema. If deserialization still
//! fails after stripping (e.g. a field's type changed), we fall back to an
//! empty delta preserving only the timestamp.
//! This module provides schema-aware stripping: before deserializing, we walk
//! the JSON value alongside the current `AppConfig` schema and remove any keys
//! that don't exist in the schema. If deserialization still fails after
//! stripping (e.g. a field's type changed), we fall back to an empty config.

use chrono::Utc;
use jp_config::{AppConfig, PartialAppConfig, Schema, SchemaType};
use serde_json::Value;
use tracing::warn;

use crate::{parse_dt, stream::ConfigDelta};

/// Deserialize a `ConfigDelta` from a raw JSON value, tolerating schema
/// changes.
/// Deserialize a [`PartialAppConfig`] from a raw JSON value, tolerating
/// schema changes.
///
/// 1. Strips unknown fields from the `delta` subtree using the current
/// `AppConfig` schema.
/// 1. Strips unknown fields using the current [`AppConfig`] schema.
/// 2. Attempts typed deserialization.
/// 3. If that fails (e.g. a type changed), falls back to an empty delta with
/// just the timestamp preserved.
pub fn deserialize_config_delta(mut value: Value) -> ConfigDelta {
/// 3. If that fails (e.g. a field's type changed), falls back to
/// [`PartialAppConfig::empty()`].
///
/// Used for both the base config snapshot (`base_config.json`) and config delta
/// events in the event stream.
pub fn deserialize_partial_config(mut value: Value) -> PartialAppConfig {
let schema = AppConfig::schema();

if let Some(delta) = value.get_mut("delta") {
let stripped = strip_unknown_fields(delta, &schema);
if stripped > 0 {
warn!(
count = stripped,
"Stripped unknown fields from stored config delta.",
);
}
let stripped = strip_unknown_fields(&mut value, &schema);
if stripped > 0 {
warn!(
count = stripped,
"Stripped unknown fields from stored config.",
);
}

match serde_json::from_value::<ConfigDelta>(value.clone()) {
Ok(delta) => delta,
match serde_json::from_value::<PartialAppConfig>(value) {
Ok(config) => config,
Err(err) => {
warn!(
error = %err,
"Config delta incompatible with current schema, replacing with empty delta.",
"Stored config incompatible with current schema, replacing with empty config.",
);
fallback_config_delta(&value)
PartialAppConfig::empty()
}
}
}

/// Extract just the timestamp from the raw JSON and build an empty delta.
fn fallback_config_delta(value: &Value) -> ConfigDelta {
let timestamp = value
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(|s| parse_dt(s).ok())
.unwrap_or_else(Utc::now);

ConfigDelta {
timestamp,
delta: Box::new(PartialAppConfig::empty()),
}
}

/// Recursively strip JSON object keys that don't exist in the schema.
///
/// At each [`SchemaType::Struct`] level, retains only keys present in the
Expand All @@ -79,7 +61,7 @@ fn fallback_config_delta(value: &Value) -> ConfigDelta {
///
/// [`flatten`]: jp_config::schema::SchemaField::flatten
fn strip_unknown_fields(value: &mut Value, schema: &Schema) -> usize {
let SchemaType::Struct(ref struct_type) = schema.ty else {
let SchemaType::Struct(struct_type) = &schema.ty else {
return 0;
};

Expand Down
Loading
Loading