perf: optimize persist delta gating and diagnostics

This commit is contained in:
Youzini-afk
2026-04-13 16:11:22 +08:00
parent 8f7572b615
commit b16785e56f
30 changed files with 4495 additions and 47 deletions

View File

@@ -0,0 +1,851 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as JsonValue};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutNode {
x: f64,
y: f64,
#[serde(default)]
vx: f64,
#[serde(default)]
vy: f64,
#[serde(default)]
pinned: bool,
#[serde(default)]
radius: f64,
#[serde(default)]
region_key: String,
#[serde(default)]
region_rect: RegionRect,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutEdge {
from: usize,
to: usize,
#[serde(default = "default_strength")]
strength: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutConfig {
#[serde(default = "default_iterations")]
iterations: u32,
#[serde(default = "default_repulsion")]
repulsion: f64,
#[serde(default = "default_spring_k")]
spring_k: f64,
#[serde(default = "default_damping")]
damping: f64,
#[serde(default = "default_center_gravity")]
center_gravity: f64,
#[serde(default = "default_min_gap")]
min_gap: f64,
#[serde(default = "default_speed_cap")]
speed_cap: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutPayload {
#[serde(default)]
nodes: Vec<LayoutNode>,
#[serde(default)]
edges: Vec<LayoutEdge>,
#[serde(default)]
config: Option<LayoutConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegionRect {
#[serde(default)]
x: f64,
#[serde(default)]
y: f64,
#[serde(default)]
w: f64,
#[serde(default)]
h: f64,
}
impl Default for RegionRect {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LayoutDiagnostics {
solver: String,
node_count: usize,
edge_count: usize,
iterations: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LayoutResult {
ok: bool,
used_native: bool,
positions: Vec<f32>,
diagnostics: LayoutDiagnostics,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistSnapshot {
#[serde(default)]
meta: JsonMap<String, JsonValue>,
#[serde(default)]
state: JsonMap<String, JsonValue>,
#[serde(default)]
nodes: Vec<JsonValue>,
#[serde(default)]
edges: Vec<JsonValue>,
#[serde(default)]
tombstones: Vec<JsonValue>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistDeltaPayload {
#[serde(default)]
before_snapshot: PersistSnapshot,
#[serde(default)]
after_snapshot: PersistSnapshot,
#[serde(default)]
now_ms: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PersistDeltaResult {
upsert_nodes: Vec<JsonValue>,
upsert_edges: Vec<JsonValue>,
delete_node_ids: Vec<String>,
delete_edge_ids: Vec<String>,
tombstones: Vec<JsonValue>,
runtime_meta_patch: JsonMap<String, JsonValue>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistCompactRecordSet {
#[serde(default)]
ids: Vec<String>,
#[serde(default)]
serialized: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistCompactTombstoneSet {
#[serde(default)]
ids: Vec<String>,
#[serde(default)]
serialized: Vec<String>,
#[serde(default)]
target_keys: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistDeltaCompactPayload {
#[serde(default)]
before_nodes: PersistCompactRecordSet,
#[serde(default)]
after_nodes: PersistCompactRecordSet,
#[serde(default)]
before_edges: PersistCompactRecordSet,
#[serde(default)]
after_edges: PersistCompactRecordSet,
#[serde(default)]
before_tombstones: PersistCompactRecordSet,
#[serde(default)]
after_tombstones: PersistCompactTombstoneSet,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PersistDeltaIdResult {
upsert_node_ids: Vec<String>,
upsert_edge_ids: Vec<String>,
delete_node_ids: Vec<String>,
delete_edge_ids: Vec<String>,
upsert_tombstone_ids: Vec<String>,
}
fn default_iterations() -> u32 {
80
}
fn default_repulsion() -> f64 {
2800.0
}
fn default_spring_k() -> f64 {
0.048
}
fn default_damping() -> f64 {
0.88
}
fn default_center_gravity() -> f64 {
0.014
}
fn default_min_gap() -> f64 {
12.0
}
fn default_speed_cap() -> f64 {
3.8
}
fn default_strength() -> f64 {
0.5
}
fn clamp(value: f64, min: f64, max: f64) -> f64 {
if value < min {
min
} else if value > max {
max
} else {
value
}
}
fn clamp_node_to_region(x: &mut f64, y: &mut f64, radius: f64, rect: &RegionRect) {
let safe_radius = radius.max(1.0) + 6.0;
let min_x = rect.x + safe_radius;
let max_x = rect.x + rect.w - safe_radius;
let min_y = rect.y + safe_radius;
let max_y = rect.y + rect.h - safe_radius;
*x = clamp(*x, min_x, max_x);
*y = clamp(*y, min_y, max_y);
}
fn normalize_json_record_id(value: Option<&JsonValue>) -> String {
value
.and_then(JsonValue::as_str)
.map(|item| item.trim().to_string())
.unwrap_or_default()
}
fn normalize_json_number_i64(value: Option<&JsonValue>, fallback: i64) -> i64 {
match value {
Some(JsonValue::Number(number)) => number.as_f64().unwrap_or(fallback as f64).floor() as i64,
Some(JsonValue::String(text)) => text.parse::<f64>().ok().map(|item| item.floor() as i64).unwrap_or(fallback),
_ => fallback,
}
}
fn sanitize_json_records(records: Vec<JsonValue>) -> Vec<JsonValue> {
records
.into_iter()
.filter(|record| record.is_object())
.filter(|record| normalize_json_record_id(record.get("id")).is_empty() == false)
.collect()
}
fn sanitize_persist_snapshot(snapshot: PersistSnapshot) -> PersistSnapshot {
PersistSnapshot {
meta: snapshot.meta,
state: snapshot.state,
nodes: sanitize_json_records(snapshot.nodes),
edges: sanitize_json_records(snapshot.edges),
tombstones: sanitize_json_records(snapshot.tombstones),
}
}
fn build_json_serialized_index(records: &[JsonValue]) -> HashMap<String, String> {
let mut map = HashMap::new();
for record in records {
let id = normalize_json_record_id(record.get("id"));
if id.is_empty() {
continue;
}
let serialized = serde_json::to_string(record).unwrap_or_else(|_| "null".to_string());
map.insert(id, serialized);
}
map
}
fn build_json_value_index(records: &[JsonValue]) -> HashMap<String, JsonValue> {
let mut map = HashMap::new();
for record in records {
let id = normalize_json_record_id(record.get("id"));
if id.is_empty() {
continue;
}
map.insert(id, record.clone());
}
map
}
fn build_runtime_meta_patch(snapshot: &PersistSnapshot) -> JsonMap<String, JsonValue> {
const RESERVED_KEYS: [&str; 8] = [
"revision",
"lastModified",
"nodeCount",
"edgeCount",
"tombstoneCount",
"syncDirty",
"syncDirtyReason",
"lastMutationReason",
];
let mut patch = JsonMap::new();
for (key, value) in &snapshot.meta {
if RESERVED_KEYS.contains(&key.as_str()) {
continue;
}
patch.insert(key.clone(), value.clone());
}
patch.insert(
"lastProcessedFloor".to_string(),
JsonValue::from(normalize_json_number_i64(
snapshot.state.get("lastProcessedFloor"),
-1,
)),
);
patch.insert(
"extractionCount".to_string(),
JsonValue::from(normalize_json_number_i64(
snapshot.state.get("extractionCount"),
0,
)),
);
patch.insert("schemaVersion".to_string(), JsonValue::from(1));
patch.insert(
"chatId".to_string(),
JsonValue::from(normalize_json_record_id(snapshot.meta.get("chatId"))),
);
patch
}
fn ensure_delete_tombstone(
tombstone_map: &mut HashMap<String, JsonValue>,
kind: &str,
target_id: &str,
deleted_at: i64,
source_device_id: &str,
) {
let normalized_kind = kind.trim();
let normalized_target_id = target_id.trim();
if normalized_kind.is_empty() || normalized_target_id.is_empty() {
return;
}
let key = format!("{}:{}", normalized_kind, normalized_target_id);
if tombstone_map.contains_key(&key) {
return;
}
let mut record = JsonMap::new();
record.insert("id".to_string(), JsonValue::from(key.clone()));
record.insert("kind".to_string(), JsonValue::from(normalized_kind));
record.insert("targetId".to_string(), JsonValue::from(normalized_target_id));
record.insert("deletedAt".to_string(), JsonValue::from(deleted_at));
record.insert(
"sourceDeviceId".to_string(),
JsonValue::from(source_device_id.trim().to_string()),
);
tombstone_map.insert(key, JsonValue::Object(record));
}
fn solve_persist_delta_in_rust(payload: PersistDeltaPayload) -> PersistDeltaResult {
let before_snapshot = sanitize_persist_snapshot(payload.before_snapshot);
let after_snapshot = sanitize_persist_snapshot(payload.after_snapshot);
let now_ms = payload.now_ms.unwrap_or(0.0).floor() as i64;
let deleted_at = if now_ms > 0 { now_ms } else { 0 };
let before_node_json_by_id = build_json_serialized_index(&before_snapshot.nodes);
let after_node_json_by_id = build_json_serialized_index(&after_snapshot.nodes);
let before_edge_json_by_id = build_json_serialized_index(&before_snapshot.edges);
let after_edge_json_by_id = build_json_serialized_index(&after_snapshot.edges);
let before_tombstone_json_by_id = build_json_serialized_index(&before_snapshot.tombstones);
let after_node_by_id = build_json_value_index(&after_snapshot.nodes);
let after_edge_by_id = build_json_value_index(&after_snapshot.edges);
let after_tombstone_by_id = build_json_value_index(&after_snapshot.tombstones);
let mut upsert_nodes = Vec::new();
for (id, record) in &after_node_by_id {
if before_node_json_by_id.get(id)
!= Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string()))
{
upsert_nodes.push(record.clone());
}
}
let mut upsert_edges = Vec::new();
for (id, record) in &after_edge_by_id {
if before_edge_json_by_id.get(id)
!= Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string()))
{
upsert_edges.push(record.clone());
}
}
let mut delete_node_ids = Vec::new();
for id in before_node_json_by_id.keys() {
if !after_node_json_by_id.contains_key(id) {
delete_node_ids.push(id.clone());
}
}
let mut delete_edge_ids = Vec::new();
for id in before_edge_json_by_id.keys() {
if !after_edge_json_by_id.contains_key(id) {
delete_edge_ids.push(id.clone());
}
}
let mut tombstone_map = HashMap::new();
for (id, record) in &after_tombstone_by_id {
if before_tombstone_json_by_id.get(id)
!= Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string()))
{
let kind = normalize_json_record_id(record.get("kind"));
let target_id = normalize_json_record_id(record.get("targetId"));
if kind.is_empty() || target_id.is_empty() {
continue;
}
tombstone_map.insert(format!("{}:{}", kind, target_id), record.clone());
}
}
let source_device_id = normalize_json_record_id(
after_snapshot
.meta
.get("deviceId")
.or_else(|| before_snapshot.meta.get("deviceId")),
);
for node_id in &delete_node_ids {
ensure_delete_tombstone(
&mut tombstone_map,
"node",
node_id,
deleted_at,
&source_device_id,
);
}
for edge_id in &delete_edge_ids {
ensure_delete_tombstone(
&mut tombstone_map,
"edge",
edge_id,
deleted_at,
&source_device_id,
);
}
PersistDeltaResult {
upsert_nodes,
upsert_edges,
delete_node_ids,
delete_edge_ids,
tombstones: tombstone_map.into_values().collect(),
runtime_meta_patch: build_runtime_meta_patch(&after_snapshot),
}
}
fn build_compact_serialized_lookup<'a>(
ids: &'a [String],
serialized: &'a [String],
) -> HashMap<&'a str, &'a str> {
let mut map = HashMap::new();
let len = ids.len().min(serialized.len());
for index in 0..len {
let id = ids[index].trim();
if id.is_empty() {
continue;
}
map.insert(id, serialized[index].as_str());
}
map
}
fn build_compact_target_key_lookup<'a>(
ids: &'a [String],
target_keys: &'a [String],
) -> HashMap<&'a str, &'a str> {
let mut map = HashMap::new();
let len = ids.len().min(target_keys.len());
for index in 0..len {
let id = ids[index].trim();
if id.is_empty() {
continue;
}
map.insert(id, target_keys[index].trim());
}
map
}
fn solve_persist_delta_compact_in_rust(payload: PersistDeltaCompactPayload) -> PersistDeltaIdResult {
let before_node_json_by_id =
build_compact_serialized_lookup(&payload.before_nodes.ids, &payload.before_nodes.serialized);
let after_node_json_by_id =
build_compact_serialized_lookup(&payload.after_nodes.ids, &payload.after_nodes.serialized);
let before_edge_json_by_id =
build_compact_serialized_lookup(&payload.before_edges.ids, &payload.before_edges.serialized);
let after_edge_json_by_id =
build_compact_serialized_lookup(&payload.after_edges.ids, &payload.after_edges.serialized);
let before_tombstone_json_by_id = build_compact_serialized_lookup(
&payload.before_tombstones.ids,
&payload.before_tombstones.serialized,
);
let after_tombstone_target_key_by_id = build_compact_target_key_lookup(
&payload.after_tombstones.ids,
&payload.after_tombstones.target_keys,
);
let mut upsert_node_ids = Vec::new();
let after_node_len = payload
.after_nodes
.ids
.len()
.min(payload.after_nodes.serialized.len());
for index in 0..after_node_len {
let id = payload.after_nodes.ids[index].trim();
if id.is_empty() {
continue;
}
let serialized = payload.after_nodes.serialized[index].as_str();
if before_node_json_by_id.get(id) != Some(&serialized) {
upsert_node_ids.push(id.to_string());
}
}
let mut upsert_edge_ids = Vec::new();
let after_edge_len = payload
.after_edges
.ids
.len()
.min(payload.after_edges.serialized.len());
for index in 0..after_edge_len {
let id = payload.after_edges.ids[index].trim();
if id.is_empty() {
continue;
}
let serialized = payload.after_edges.serialized[index].as_str();
if before_edge_json_by_id.get(id) != Some(&serialized) {
upsert_edge_ids.push(id.to_string());
}
}
let mut delete_node_ids = Vec::new();
for id in &payload.before_nodes.ids {
let normalized_id = id.trim();
if normalized_id.is_empty() {
continue;
}
if !after_node_json_by_id.contains_key(normalized_id) {
delete_node_ids.push(normalized_id.to_string());
}
}
let mut delete_edge_ids = Vec::new();
for id in &payload.before_edges.ids {
let normalized_id = id.trim();
if normalized_id.is_empty() {
continue;
}
if !after_edge_json_by_id.contains_key(normalized_id) {
delete_edge_ids.push(normalized_id.to_string());
}
}
let mut upsert_tombstone_ids = Vec::new();
let after_tombstone_len = payload
.after_tombstones
.ids
.len()
.min(payload.after_tombstones.serialized.len());
for index in 0..after_tombstone_len {
let id = payload.after_tombstones.ids[index].trim();
if id.is_empty() {
continue;
}
let target_key = after_tombstone_target_key_by_id
.get(id)
.copied()
.unwrap_or_default();
if target_key.is_empty() {
continue;
}
let serialized = payload.after_tombstones.serialized[index].as_str();
if before_tombstone_json_by_id.get(id) != Some(&serialized) {
upsert_tombstone_ids.push(id.to_string());
}
}
PersistDeltaIdResult {
upsert_node_ids,
upsert_edge_ids,
delete_node_ids,
delete_edge_ids,
upsert_tombstone_ids,
}
}
fn build_region_buckets(nodes: &[LayoutNode]) -> HashMap<String, Vec<usize>> {
let mut region_buckets = HashMap::new();
for (index, node) in nodes.iter().enumerate() {
region_buckets
.entry(node.region_key.clone())
.or_insert_with(Vec::new)
.push(index);
}
region_buckets
}
fn build_region_spring_ideals(nodes: &[LayoutNode]) -> HashMap<String, f64> {
let mut count_by_region: HashMap<String, usize> = HashMap::new();
let mut area_by_region: HashMap<String, f64> = HashMap::new();
for node in nodes {
*count_by_region.entry(node.region_key.clone()).or_insert(0) += 1;
area_by_region
.entry(node.region_key.clone())
.or_insert_with(|| {
let area = node.region_rect.w.max(1.0) * node.region_rect.h.max(1.0);
area.max(1.0)
});
}
let mut result = HashMap::new();
for (region_key, count) in count_by_region {
let area = *area_by_region.get(&region_key).unwrap_or(&1.0);
let count_f64 = (count.max(1)) as f64;
let ideal = (0.78 * (area / count_f64).sqrt()).clamp(36.0, 92.0);
result.insert(region_key, ideal);
}
result
}
fn build_in_region_edges(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Vec<(usize, usize, f64)> {
let mut result = Vec::new();
for edge in edges {
if edge.from >= nodes.len() || edge.to >= nodes.len() || edge.from == edge.to {
continue;
}
if nodes[edge.from].region_key != nodes[edge.to].region_key {
continue;
}
result.push((edge.from, edge.to, edge.strength));
}
result
}
fn solve_layout_in_rust(payload: LayoutPayload) -> LayoutResult {
let config = payload.config.unwrap_or(LayoutConfig {
iterations: default_iterations(),
repulsion: default_repulsion(),
spring_k: default_spring_k(),
damping: default_damping(),
center_gravity: default_center_gravity(),
min_gap: default_min_gap(),
speed_cap: default_speed_cap(),
});
let mut nodes = payload.nodes;
let edge_count = payload.edges.len();
if nodes.is_empty() {
return LayoutResult {
ok: true,
used_native: true,
positions: Vec::new(),
diagnostics: LayoutDiagnostics {
solver: "rust-wasm".to_string(),
node_count: 0,
edge_count,
iterations: 0,
},
};
}
let iterations = clamp(config.iterations as f64, 8.0, 220.0) as u32;
let repulsion = clamp(config.repulsion, 100.0, 120_000.0);
let spring_k = clamp(config.spring_k, 0.001, 1.0);
let damping = clamp(config.damping, 0.1, 0.999);
let center_gravity = clamp(config.center_gravity, 0.0001, 1.0);
let min_gap = clamp(config.min_gap, 0.0, 120.0);
let speed_cap = clamp(config.speed_cap, 0.5, 20.0);
for node in &mut nodes {
node.radius = node.radius.max(1.0);
}
let region_buckets = build_region_buckets(&nodes);
let spring_ideal_by_region = build_region_spring_ideals(&nodes);
let in_region_edges = build_in_region_edges(&nodes, &payload.edges);
let mut center_x = vec![0.0_f64; nodes.len()];
let mut center_y = vec![0.0_f64; nodes.len()];
for (index, node) in nodes.iter().enumerate() {
center_x[index] = node.region_rect.x + node.region_rect.w / 2.0;
center_y[index] = node.region_rect.y + node.region_rect.h / 2.0;
}
let mut fx = vec![0.0_f64; nodes.len()];
let mut fy = vec![0.0_f64; nodes.len()];
let mut actual_iterations = 0_u32;
let mut stable_rounds = 0_u32;
for _ in 0..iterations {
actual_iterations += 1;
fx.fill(0.0);
fy.fill(0.0);
for bucket in region_buckets.values() {
for left in 0..bucket.len() {
let i = bucket[left];
for right in (left + 1)..bucket.len() {
let j = bucket[right];
let dx = nodes[j].x - nodes[i].x;
let dy = nodes[j].y - nodes[i].y;
let mut dist_sq = dx * dx + dy * dy;
if dist_sq < 0.25 {
dist_sq = 0.25;
}
let dist = dist_sq.sqrt();
let min_sep = nodes[i].radius + nodes[j].radius + min_gap;
let mut force = repulsion / dist_sq;
if dist < min_sep {
force += (min_sep - dist) * 0.22;
}
let inv_dist = if dist > 0.0 { 1.0 / dist } else { 0.0 };
let force_x = dx * inv_dist * force;
let force_y = dy * inv_dist * force;
fx[i] -= force_x;
fy[i] -= force_y;
fx[j] += force_x;
fy[j] += force_y;
}
}
}
for (from, to, strength) in &in_region_edges {
let from_index = *from;
let to_index = *to;
let strength_value = *strength;
let dx = nodes[to_index].x - nodes[from_index].x;
let dy = nodes[to_index].y - nodes[from_index].y;
let dist = (dx * dx + dy * dy).sqrt().max(0.001);
let ideal = *spring_ideal_by_region
.get(&nodes[from_index].region_key)
.unwrap_or(&68.0);
let displacement = dist - ideal * (0.82 + 0.18 * strength_value);
let force = spring_k * displacement * (0.45 + 0.55 * strength_value);
let force_x = (dx / dist) * force;
let force_y = (dy / dist) * force;
fx[from_index] += force_x;
fy[from_index] += force_y;
fx[to_index] -= force_x;
fy[to_index] -= force_y;
}
let mut max_speed = 0.0_f64;
for (index, node) in nodes.iter_mut().enumerate() {
fx[index] += (center_x[index] - node.x) * center_gravity;
fy[index] += (center_y[index] - node.y) * center_gravity;
if node.pinned {
continue;
}
node.vx = (node.vx + fx[index]) * damping;
node.vy = (node.vy + fy[index]) * damping;
let speed = (node.vx * node.vx + node.vy * node.vy).sqrt();
if speed > max_speed {
max_speed = speed;
}
if speed > speed_cap {
let scale = speed_cap / speed;
node.vx *= scale;
node.vy *= scale;
}
node.x += node.vx;
node.y += node.vy;
clamp_node_to_region(&mut node.x, &mut node.y, node.radius, &node.region_rect);
}
if max_speed < 0.015 {
stable_rounds += 1;
if stable_rounds >= 6 {
break;
}
} else {
stable_rounds = 0;
}
}
let node_count = nodes.len();
let mut positions = Vec::with_capacity(nodes.len() * 2);
for node in nodes {
positions.push(node.x as f32);
positions.push(node.y as f32);
}
LayoutResult {
ok: true,
used_native: true,
positions,
diagnostics: LayoutDiagnostics {
solver: "rust-wasm".to_string(),
node_count,
edge_count,
iterations: actual_iterations,
},
}
}
#[wasm_bindgen]
pub fn solve_layout(payload: JsValue) -> Result<JsValue, JsValue> {
let parsed: LayoutPayload = serde_wasm_bindgen::from_value(payload)
.map_err(|error| JsValue::from_str(&format!("invalid payload: {error}")))?;
let solved = solve_layout_in_rust(parsed);
serde_wasm_bindgen::to_value(&solved)
.map_err(|error| JsValue::from_str(&format!("serialize result failed: {error}")))
}
#[wasm_bindgen]
pub fn build_persist_delta(payload: JsValue) -> Result<JsValue, JsValue> {
let parsed: PersistDeltaPayload = serde_wasm_bindgen::from_value(payload)
.map_err(|error| JsValue::from_str(&format!("invalid persist payload: {error}")))?;
let solved = solve_persist_delta_in_rust(parsed);
serde_wasm_bindgen::to_value(&solved)
.map_err(|error| JsValue::from_str(&format!("serialize persist result failed: {error}")))
}
#[wasm_bindgen]
pub fn build_persist_delta_compact(payload: JsValue) -> Result<JsValue, JsValue> {
let parsed: PersistDeltaCompactPayload = serde_wasm_bindgen::from_value(payload)
.map_err(|error| JsValue::from_str(&format!("invalid compact persist payload: {error}")))?;
let solved = solve_persist_delta_compact_in_rust(parsed);
serde_wasm_bindgen::to_value(&solved).map_err(|error| {
JsValue::from_str(&format!("serialize compact persist result failed: {error}"))
})
}