brep_app/panels/update_components.rs
1//! # Staleness (recompute on a cheap trigger, never per frame)
2//!
3//! The comparison reads the store once per entry — too hot to recompute on
4//! every panel draw. [`UpdateComponents::ensure_current`] caches the result
5//! keyed on `(EngineState::applied_generation, History::revision,
6//! FileDialog::save_generation, ModelStore::mutation_generation)`:
7//!
8//! * an applied history run bumps the first half (covers opening an assembly,
9//! inserting components, an update-components refresh, constraint solves);
10//! * a successful store save bumps the third (covers Edit Part → edit → save
11//! in the part's own tab → back to the assembly tab: the badge lights
12//! without a restart);
13//! * ANY other write or delete through the store bumps the fourth.
14//!
15//! The fourth is not redundant with the third. `save_generation` counts what
16//! the Save dialog did; the comparison depends on what the STORE holds, and
17//! the dialog is not the only thing that writes to it. `kicad_bulk::write_part`
18//! re-imports a part onto a document name an open assembly may already
19//! reference, and the explorer's delete row (`panels::file`'s `del:` handler)
20//! takes a source away — neither touches the assembly document, so neither
21//! moved any of the other three, and the badge stayed dark until something
22//! unrelated moved. Keying on the store's own mutation counter covers every
23//! door into it by construction, including doors not written yet.
24//!
25//! Entries with an EMPTY `sourceKey` (embedded-only parts) are skipped;
26//! entries whose key has no store document are NOTED (surfaced in the header
27//! hover + a run-time notice) but never counted — they cannot be refreshed.
28//!
29//! The comparison only means "outdated" because every writer keeps the entry's
30//! `sourceSignature` equal to what the FILE holds: the insert lane hashes the
31//! file it read, [`UpdateComponents::run`] hashes the file it just pulled in,
32//! and an in-document part edit SAVES the part back to its `sourceKey` before
33//! stamping the signature (`panels::parts_library`'s write-through lane).
34//! Without that last one an in-context edit read as outdated against a file it
35//! was newer than — the badge meaning the opposite of what happened.
36
37use crate::panels::parts_library::{document_signature, refresh_library_entry};
38use crate::store::ModelStore;
39use brep_render::engine_state::{EngineState, NoticeSeverity};
40use crate::store::{ReadNow, Residency};
41use eframe::egui;
42use serde_json::Value;
43
44/// The shell-owned outdated-parts checker + batch refresher. One instance on
45/// the app; the constraints panel reads the count (and runs the refresh), the
46/// structure tree reads per-part flags.
47#[derive(Default)]
48pub struct UpdateComponents {
49 /// Parts-library entry names whose store content no longer matches their
50 /// `sourceSignature` — the badge count.
51 outdated: Vec<String>,
52 /// Entry names with a `sourceKey` but NO store document under it (noted,
53 /// never counted — nothing to refresh from).
54 missing: Vec<String>,
55 /// The `(applied_generation, history_revision, save_generation,
56 /// store_mutation_generation)` key the cache was computed against; `None`
57 /// forces a recompute on the next ensure.
58 checked: Option<(u64, u64, u64, u64)>,
59 /// The tab's synced signatures last passed to [`Self::ensure_current`]
60 /// ([`compute`]); [`Self::run`] judges with the same ones.
61 synced: std::collections::HashMap<String, String>,
62 /// The signatures this tab took from the files with Update since that
63 /// Open/Save, by entry. Such an entry has moved off its synced version,
64 /// but by taking the file's, not by an edit made here: a second Update
65 /// before a Save must not ask about it.
66 taken: std::collections::HashMap<String, String>,
67 /// The file version (`document_signature`) each entry's synced copy was
68 /// judged to be, since that Open/Save. An entry whose file is still that
69 /// version is not outdated whatever its copy became since: a
70 /// sub-assembly re-signed by a nested part's refresh, or edited here. (Judged by
71 /// signature and content alone, such a sub-assembly read as outdated
72 /// once its child was refreshed, and a second Update would have put the
73 /// old part back from the sub-assembly's file.)
74 seen_files: std::collections::HashMap<String, std::collections::BTreeSet<String>>,
75 /// Outdated parts that were ALSO edited in this document since it was
76 /// opened or saved: Update would discard that edit, so [`Self::run`] asks
77 /// first ([`Self::show_conflict`]).
78 asking: Option<Vec<String>>,
79 /// The answer to that question for the run it resumes: `true` takes the
80 /// files' versions, `false` keeps this document's.
81 answer: Option<bool>,
82 /// Each entry's last verdict, with exactly what it was judged from
83 /// ([`Verdict`]). The key above moves on every edit and every landed run,
84 /// but an entry whose signature, file text and synced version are all
85 /// unchanged is judged the same again: reading, parsing and hashing a
86 /// 160 KB definition per entry per recompute was ~80 ms at 500 parts.
87 verdicts: std::collections::HashMap<String, Verdict>,
88 /// How many entries were judged from scratch (the work the cache saves).
89 judged: usize,
90}
91
92/// One entry's "has this tab seen its file's version" answer, and its inputs.
93#[derive(Debug, Clone)]
94struct Verdict {
95 signature: String,
96 synced: Option<String>,
97 contents: String,
98 seen: bool,
99}
100
101impl UpdateComponents {
102 pub fn new() -> Self {
103 Self::default()
104 }
105
106 /// Recompute the outdated/missing sets when the staleness key moved (or
107 /// after [`Self::invalidate`]); otherwise a cheap compare. The shell calls
108 /// this once per frame BEFORE the assembly panels draw.
109 ///
110 /// `synced`: each top-level entry's `sourceSignature` as of the tab's last
111 /// Open/Save ([`crate::document::Documents::synced_signatures`]); empty
112 /// when unknown, and then the entry's own signature stands alone.
113 pub fn ensure_current(
114 &mut self,
115 state: &mut EngineState,
116 model_store: &dyn ModelStore,
117 save_generation: u64,
118 synced: &std::collections::HashMap<String, String>,
119 ) {
120 if self.synced != *synced {
121 self.synced = synced.clone();
122 self.taken.clear();
123 self.seen_files.clear();
124 self.checked = None;
125 }
126 let key = (
127 state.applied_generation(),
128 state.history.revision(),
129 save_generation,
130 model_store.mutation_generation(),
131 );
132 if self.checked == Some(key) {
133 return;
134 }
135 let (outdated, missing) = compute(state, model_store, &self.synced, &mut self.verdicts, &mut self.seen_files, &mut self.judged);
136 self.outdated = outdated;
137 self.missing = missing;
138 self.checked = Some(key);
139 }
140
141 /// The parts an Update is waiting to ask about (automation readback).
142 pub fn asking(&self) -> Option<&[String]> {
143 self.asking.as_deref()
144 }
145
146 /// Answer the question [`Self::run`] asked and run the update: `true`
147 /// takes the files' versions, `false` keeps this document's edits (the
148 /// other outdated parts are updated either way).
149 pub fn answer(&mut self, state: &mut EngineState, model_store: &dyn ModelStore, take_files: bool) -> Result<usize, String> {
150 self.asking = None;
151 self.answer = Some(take_files);
152 let result = self.run(state, model_store);
153 self.answer = None;
154 result
155 }
156
157 /// The question, while one is pending: one dialog, never a block. The
158 /// default (Enter) takes the files' versions.
159 pub fn show_conflict(&mut self, ctx: &egui::Context, state: &mut EngineState, model_store: &dyn ModelStore) {
160 let Some(names) = self.asking.clone() else {
161 if crate::automation::registry::enabled() {
162 crate::automation::registry::publish("__brepUpdateComponentsHit", HIT_BLOB, "{}");
163 }
164 return;
165 };
166 let mut choice = None;
167 let mut hits = serde_json::json!({});
168 let rect = |r: egui::Rect| serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]);
169 let modal = egui::Modal::new(egui::Id::new("brep-update-components-conflict")).show(ctx, |ui| {
170 ui.set_width(460.0);
171 ui.heading("Update components");
172 ui.label(format!(
173 "{} changed in its file and was also edited in this document. Updating takes the file's version and discards the edit made here.",
174 names.join(", ")
175 ));
176 ui.add_space(8.0);
177 ui.horizontal(|ui| {
178 let file = ui.button("Take the file's version");
179 hits["update:file"] = rect(file.rect);
180 if file.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
181 choice = Some(true);
182 }
183 let mine = ui.button("Keep mine");
184 hits["update:mine"] = rect(mine.rect);
185 if mine.clicked() {
186 choice = Some(false);
187 }
188 });
189 });
190 if crate::automation::registry::enabled() {
191 crate::automation::registry::publish("__brepUpdateComponentsHit", HIT_BLOB, &hits.to_string());
192 }
193 let choice = choice.or_else(|| modal.should_close().then_some(false));
194 if let Some(take_files) = choice {
195 if let Err(error) = self.answer(state, model_store, take_files) {
196 state.push_notice(format!("Update components: {error}"));
197 }
198 }
199 }
200
201 /// Drop the cache so the next [`Self::ensure_current`] recomputes even on
202 /// an unmoved key (after a run, or an external store change).
203 pub fn invalidate(&mut self) {
204 self.checked = None;
205 }
206
207 /// The outdated-entry count — the constraints-header badge number.
208 pub fn outdated_count(&self) -> usize {
209 self.outdated.len()
210 }
211
212 /// The outdated entry names — what the Diagram and PCB workbenches mark on
213 /// the components placed from them.
214 pub fn outdated(&self) -> &[String] {
215 &self.outdated
216 }
217
218 /// Whether `part_name`'s library entry is outdated — the structure tree's
219 /// per-node badge (every instance of the part lights).
220 pub fn is_outdated(&self, part_name: &str) -> bool {
221 self.outdated.iter().any(|part| part == part_name)
222 }
223
224 /// Entry names noted as source-less (header hover text).
225 pub fn missing(&self) -> &[String] {
226 &self.missing
227 }
228
229 /// RUN the update (the header button): recompute against the LIVE document
230 /// (never a stale cache), rewrite every outdated entry from its store
231 /// content through [`refresh_library_entry`], then reload ONCE — the
232 /// kernel self-heals every instance + re-solves, and the main-side sync
233 /// captures healed snapshots back. Per-entry failures (missing store key,
234 /// unreadable content) toast and SKIP — the batch never aborts. Returns
235 /// the number of entries refreshed.
236 pub fn run(
237 &mut self,
238 state: &mut EngineState,
239 model_store: &dyn ModelStore,
240 ) -> Result<usize, String> {
241 let (mut outdated, missing) = compute(state, model_store, &self.synced, &mut self.verdicts, &mut self.seen_files, &mut self.judged);
242 // Edited HERE as well as changed in the file: ask before discarding the
243 // edit (default: take the file's version). Edited here = the entry's
244 // signature moved off the version the tab synced with.
245 let edited_here: Vec<String> = edited_here(state, &outdated, &self.synced, &self.taken);
246 if !edited_here.is_empty() {
247 match self.answer.take() {
248 None => {
249 self.asking = Some(edited_here);
250 return Ok(0);
251 }
252 Some(true) => {}
253 Some(false) => outdated.retain(|name| !edited_here.contains(name)),
254 }
255 }
256 if !missing.is_empty() {
257 state.push_notice_as(NoticeSeverity::Warning, format!(
258 "Update components: no source document for {} — skipped",
259 missing.join(", ")
260 ));
261 }
262 if outdated.is_empty() {
263 self.invalidate();
264 return Ok(0);
265 }
266 // Instructions take one new revision at a time: the author approves
267 // the pending re-target first (the engine refuses it as well).
268 if state.instructions().ok().flatten().is_some_and(|block| block.retarget.is_some()) {
269 return Err("the instructions' re-target waits for approval: approve it in the Instructions pane, then update again".into());
270 }
271 let mut document = state.history_request_value();
272 let mut refreshed = 0usize;
273 let mut refreshed_parts: Vec<String> = Vec::new();
274 // Every name refreshed, a nested chain included (what `taken` records).
275 let mut refreshed_names: Vec<String> = Vec::new();
276 for part_name in &outdated {
277 let source_key = crate::document::owned_save::entry_at_chain(&document["partsLibrary"], part_name)
278 .and_then(|entry| entry["sourceKey"].as_str())
279 .unwrap_or_default()
280 .to_string();
281 let contents = match crate::store::read_now(model_store, &source_key) {
282 ReadNow::Ready(contents) => contents,
283 ReadNow::Loading => {
284 state.push_notice_as(NoticeSeverity::Info, format!(
285 "Update components: '{part_name}' is still loading from the store — run the update again in a moment"
286 ));
287 continue;
288 }
289 ReadNow::Absent => {
290 state.push_notice_as(NoticeSeverity::Warning, format!(
291 "Update components: '{part_name}' source '{source_key}' missing — skipped"
292 ));
293 continue;
294 }
295 };
296 // A nested part: every copy of its file, at every depth, takes
297 // the new version, and each sub-assembly above it is re-signed
298 // and loses its snapshot (the Work part push's refresh). Its
299 // mates are captured on the top-level occurrences holding it.
300 if let Some((top, _)) = part_name.split_once('/') {
301 let refreshed_nested = serde_json::from_str::<Value>(&contents).map_err(|error| error.to_string()).and_then(|edited| {
302 let mut notes = Vec::new();
303 crate::document::work_part::refresh_parts(&mut document, &[], Some(&source_key), &edited, &mut notes).map(|changed| (changed, notes))
304 });
305 match refreshed_nested {
306 Ok((true, notes)) => {
307 refreshed += 1;
308 refreshed_names.push(part_name.clone());
309 if !refreshed_parts.iter().any(|name| name == top) {
310 refreshed_parts.push(top.to_string());
311 }
312 for line in notes {
313 state.push_notice_as(NoticeSeverity::Warning, format!("Update components: '{part_name}': {line}"));
314 }
315 }
316 Ok((false, _)) => {}
317 Err(error) => state.push_notice(format!("Update components: '{part_name}': {error} — skipped")),
318 }
319 continue;
320 }
321 match refresh_library_entry(&mut document, part_name, &contents) {
322 Ok(unlinked) => {
323 refreshed += 1;
324 refreshed_parts.push(part_name.clone());
325 refreshed_names.push(part_name.clone());
326 // Per-component, not per-refresh: a sheet component the
327 // assembly can no longer place is named and the rest of the
328 // batch still lands.
329 for line in unlinked {
330 state.push_notice_as(NoticeSeverity::Warning, format!("Update components: '{part_name}': {line}"));
331 }
332 }
333 Err(error) => state.push_notice(format!(
334 "Update components: '{part_name}': {error} — skipped"
335 )),
336 }
337 }
338 if refreshed > 0 {
339 // The ONE reload for the whole batch (no zoom — the camera stays).
340 // The mates on the updated parts' occurrences are checked once it
341 // lands: a face the update renamed on the same plane is
342 // re-attached, anything ambiguous is held for Repair.
343 // Another open document's replay may own the thread's session.
344 state.forget_assembly_session();
345 for part_name in &refreshed_names {
346 let entry = crate::document::owned_save::entry_at_chain(&document["partsLibrary"], part_name);
347 if let Some(signature) = entry.and_then(|entry| entry["sourceSignature"].as_str()) {
348 self.taken.insert(part_name.clone(), signature.to_string());
349 }
350 }
351 let capture = state.capture_part_edit(&state.components_of_parts(&refreshed_parts));
352 state
353 .commit_part_edit(capture, document, &refreshed_parts)
354 .map_err(|error| format!("assembly reload failed: {error}"))?;
355 state.push_notice_as(NoticeSeverity::Info, format!(
356 "Updated {refreshed} part(s) from source — instances and eCAD snapshots refreshed"
357 ));
358 }
359 self.invalidate();
360 Ok(refreshed)
361 }
362}
363
364const HIT_BLOB: &str = "Update components' question when a part was edited here AND changed in its file: update:file (default), update:mine";
365
366pub static HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] = &[crate::automation::hit_keys::HitKeyDoc {
367 panel: "updatecomponents",
368 prefix: "update:",
369 meaning: "Update components' question for a part edited here AND changed in its file: update:file (take the file's version, the default), update:mine (keep this document's edit)",
370 command: None,
371}];
372
373/// Of `outdated`, the parts this document ALSO edited since its last
374/// Open/Save: the entry's signature is no longer the one it synced with, nor
375/// one an Update took from the file (`taken`).
376fn edited_here(
377 state: &EngineState,
378 outdated: &[String],
379 synced: &std::collections::HashMap<String, String>,
380 taken: &std::collections::HashMap<String, String>,
381) -> Vec<String> {
382 let library = state.history.parts_library();
383 outdated
384 .iter()
385 .filter(|name| {
386 synced.get(*name).is_some_and(|synced| {
387 crate::document::owned_save::entry_at_chain(library, name)
388 .and_then(|entry| entry["sourceSignature"].as_str())
389 .is_some_and(|now| now != synced && taken.get(*name).is_none_or(|taken| taken != now))
390 })
391 })
392 .cloned()
393 .collect()
394}
395
396/// Whether the copy `document` of the PLM revision `source_key` is the very
397/// bytes the PLM stores for it, as the store's index lists them: then it is up
398/// to date, judged without reading the revision (the open's cached-copy check
399/// takes such a copy for its revision the same way). Reading every source of
400/// a 500-part PLM assembly cost 36 requests on every open.
401fn copy_is_revision(store: &dyn ModelStore, source_key: &str, document: &Value) -> bool {
402 let Some((part, revision)) = crate::plm::bom::revision_of_document(source_key) else { return false };
403 document["features"].is_array()
404 && store
405 .plm_revision(&crate::plm::identity::document_key(&part, &revision))
406 .is_some_and(|row| crate::plm::kicad::content_hash(document.to_string().as_bytes()) == row.content_hash)
407}
408
409/// The signature comparison over the LIVE document's `partsLibrary` block →
410/// `(outdated, missing)` entry-name sets. Componentless documents short-circuit
411/// to empty (zero cost for modeling files).
412///
413/// OUTDATED = the file holds a version this tab has never seen. The tab has
414/// seen two: the one it synced with at its last Open/Save (`synced`) and its
415/// own current copy. An edit made in the assembly (a Work part, a BOM cell, a
416/// live link from the part's open tab) re-stamps the entry's signature and
417/// leaves the file alone; judged against the entry's signature alone it read
418/// as "outdated" and offered an Update that would REVERT it.
419fn compute(
420 state: &mut EngineState,
421 model_store: &dyn ModelStore,
422 synced: &std::collections::HashMap<String, String>,
423 verdicts: &mut std::collections::HashMap<String, Verdict>,
424 seen_files: &mut std::collections::HashMap<String, std::collections::BTreeSet<String>>,
425 judged: &mut usize,
426) -> (Vec<String>, Vec<String>) {
427 let mut outdated = Vec::new();
428 let mut missing = Vec::new();
429 if !state.history_has_assembly() {
430 return (outdated, missing);
431 }
432 // The document's partsLibrary block mirrors the kernel store (heals + GC
433 // included); `apply_run_output` installs and mirrors the store of any run
434 // that moved it, so the block is already current for this generation.
435 // Read in place: serializing the whole document (and its thumbnail) to
436 // parse it back cost ~40 ms per recompute at 500 parts.
437 let history = &state.history;
438 let Some(library) = history.parts_library().as_object() else {
439 return (outdated, missing);
440 };
441 let sheets = [history.diagram_block(), history.pcb_block()];
442 for (part_name, entry) in library {
443 let Some(source_key) = entry
444 .get("sourceKey")
445 .and_then(Value::as_str)
446 .filter(|key| !key.is_empty())
447 else {
448 continue; // embedded-only part: no source to compare against
449 };
450 if sheets.iter().all(Option::is_none) && copy_is_revision(model_store, source_key, &entry["document"]) {
451 continue;
452 }
453 match crate::store::read_now(model_store, source_key) {
454 // Listed but not loaded yet (an index-hydrated store, S0): neither
455 // missing nor outdated — there is nothing to judge until the bytes
456 // land, and their landing moves the store generation this cache is
457 // keyed on, so the entry is judged then.
458 ReadNow::Loading => {}
459 ReadNow::Absent => missing.push(part_name.clone()),
460 ReadNow::Ready(contents) => {
461 let signature = entry
462 .get("sourceSignature")
463 .and_then(Value::as_str)
464 .unwrap_or_default();
465 // A component's version belongs to the DEVICE it was placed
466 // from, and a nested device's key is a key of ITS OWN parent's
467 // library — the same spelling in a different namespace. Judging
468 // one against a top-level entry read it as stale against a part
469 // it was never placed from, and NOTHING could clear the badge:
470 // `parts_library::refresh_library_entry` binds by occurrence
471 // CHAIN and correctly declines to touch a device that is not
472 // this entry's. So a chain (`ACOMP9:ACOMP1`) is not this
473 // entry's to judge. A top-level occurrence is, and so is a
474 // legacy component with no occurrence at all.
475 let stale_sheet = sheets.iter().flatten().any(|block| {
476 block["components"].as_array().is_some_and(|components| {
477 components.iter().any(|component| component["part"]["key"].as_str() == Some(part_name.as_str())
478 && !component["part"]["instance"].as_str().is_some_and(|id| id.contains(':'))
479 && component["part"]["signature"].as_str() != Some(signature))
480 })
481 });
482 let synced_now = synced.get(part_name).cloned();
483 let cached = verdicts.get(part_name).filter(|v| {
484 v.signature == signature && v.synced == synced_now && v.contents == contents
485 });
486 let seen = match cached {
487 Some(verdict) => verdict.seen,
488 None => {
489 *judged += 1;
490 let on_disk = document_signature(&contents);
491 let seen = on_disk == signature
492 || synced_now.as_ref().is_some_and(|synced| *synced == on_disk)
493 || seen_files.get(part_name).is_some_and(|files| files.contains(&on_disk))
494 || serde_json::from_str::<Value>(&contents).is_ok_and(|file| {
495 crate::document::owned_save::source_hash(&file)
496 == crate::document::owned_save::source_hash(&entry["document"])
497 });
498 // Remembered only while the copy is still the synced one:
499 // the file version the baseline is. A version an
500 // Update took is not (undoing that Update makes the
501 // entry outdated again).
502 if seen && synced_now.as_deref() == Some(signature) {
503 seen_files.entry(part_name.clone()).or_default().insert(on_disk);
504 }
505 verdicts.insert(
506 part_name.clone(),
507 Verdict { signature: signature.to_string(), synced: synced_now, contents, seen },
508 );
509 seen
510 }
511 };
512 if !seen || stale_sheet {
513 outdated.push(part_name.clone());
514 }
515 }
516 }
517 }
518 // A part that exists only INSIDE a sub-assembly has a file too: judged
519 // the same way, by its chain (`module-1/part-3`). A sub-assembly's own
520 // edit of it (a Work part push) stays "seen" through the synced version.
521 // (Only top-level entries were judged, so a changed nested part's file
522 // read as up to date and Update left the old geometry in place.)
523 for (chain, entry) in crate::document::owned_save::nested_entries(history.parts_library()) {
524 let Some(source_key) = entry.get("sourceKey").and_then(Value::as_str).filter(|key| !key.is_empty()) else {
525 continue;
526 };
527 if copy_is_revision(model_store, source_key, &entry["document"]) {
528 continue;
529 }
530 let ReadNow::Ready(contents) = crate::store::read_now(model_store, source_key) else {
531 continue; // a nested source that is absent or loading is not reported
532 };
533 let signature = entry.get("sourceSignature").and_then(Value::as_str).unwrap_or_default();
534 let synced_now = synced.get(&chain).cloned();
535 let cached = verdicts
536 .get(&chain)
537 .filter(|v| v.signature == signature && v.synced == synced_now && v.contents == contents);
538 let seen = match cached {
539 Some(verdict) => verdict.seen,
540 None => {
541 *judged += 1;
542 let on_disk = document_signature(&contents);
543 let seen = on_disk == signature
544 || synced_now.as_ref().is_some_and(|synced| *synced == on_disk)
545 || seen_files.get(&chain).is_some_and(|files| files.contains(&on_disk))
546 || serde_json::from_str::<Value>(&contents).is_ok_and(|file| {
547 crate::document::owned_save::source_hash(&file)
548 == crate::document::owned_save::source_hash(&entry["document"])
549 });
550 if seen && synced_now.as_deref() == Some(signature) {
551 seen_files.entry(chain.clone()).or_default().insert(on_disk);
552 }
553 verdicts.insert(chain.clone(), Verdict { signature: signature.to_string(), synced: synced_now, contents, seen });
554 seen
555 }
556 };
557 // One file placed at several depths is one update.
558 if !seen && !outdated.iter().any(|name| {
559 crate::document::owned_save::entry_at_chain(history.parts_library(), name)
560 .is_some_and(|other| other["sourceKey"].as_str() == Some(source_key))
561 }) {
562 outdated.push(chain);
563 }
564 }
565 (outdated, missing)
566}
567