mergeNodeUnion function

NodeMergeResult mergeNodeUnion(
  1. List<CachedNode>? existing,
  2. List<CachedNode> incoming,
  3. String ts, {
  4. DateTime? staleBefore,
  5. int maxNodes = 0,
})

Union the incoming capture into the cached node set, keyed on id, then cap it at maxNodes (0 = uncapped).

When staleBefore is non-null the capture is eligible to prove absence: a cached node missing from incoming gets kMissingSinceKey stamped (if it has none), and one whose stamp is at or before staleBefore is pruned.

The cap (see capNodes) never evicts a node present in incoming unless the capture alone exceeds maxNodes.

changed is measured on the KEPT set: an id added, an id removed (pruned or evicted), or a kept node whose wire fields moved. A node the cap drops is not "new" — otherwise an over-cap capture would re-dirty the file on every identical capture forever.

Implementation

NodeMergeResult mergeNodeUnion(
  List<CachedNode>? existing,
  List<WireNode> incoming,
  String ts, {
  DateTime? staleBefore,
  int maxNodes = 0,
}) {
  final prevById = <String, CachedNode>{};
  if (existing != null) {
    for (final n in existing) {
      final id = n['id'];
      if (id is String) prevById[id] = n;
    }
  }
  final incById = <String, WireNode>{};
  for (final inc in incoming) {
    final id = inc['id'];
    if (id is String && id.isNotEmpty) incById[id] = inc;
  }

  // Existing order first, then new ids in capture order: a stable order keeps
  // the file's content hash stable across merges.
  final byId = <String, CachedNode>{};
  for (final e in prevById.entries) {
    final id = e.key;
    final n = e.value;
    final inc = incById[id];
    if (inc != null) {
      final seen = n['_seenCount'];
      byId[id] = <String, dynamic>{
        ...inc,
        '_firstSeen': n['_firstSeen'] ?? ts,
        '_lastSeen': ts,
        '_seenCount': (seen is int ? seen : 0) + 1,
      };
      continue;
    }
    if (staleBefore == null) {
      byId[id] = Map<String, dynamic>.of(n);
      continue;
    }
    final stamp = n[kMissingSinceKey];
    final missingSince = stamp is String ? DateTime.tryParse(stamp) : null;
    if (missingSince == null) {
      // First observed absence: start the clock, prune nothing yet.
      byId[id] = <String, dynamic>{...n, kMissingSinceKey: ts};
    } else if (!missingSince.isAfter(staleBefore)) {
      // Absent from eligible changing captures spanning >= kStaleNodeMaxAge.
      continue;
    } else {
      byId[id] = Map<String, dynamic>.of(n);
    }
  }
  for (final e in incById.entries) {
    if (byId.containsKey(e.key)) continue;
    byId[e.key] = <String, dynamic>{
      ...e.value,
      '_firstSeen': ts,
      '_lastSeen': ts,
      '_seenCount': 1,
    };
  }

  final all = byId.values.toList(growable: false);
  final kept = capNodes(all, maxNodes, live: incById.keys.toSet());

  var changed = kept.length != prevById.length;
  if (!changed) {
    for (final n in kept) {
      final id = n['id'] as String;
      final prev = prevById[id];
      if (prev == null) {
        changed = true;
        break;
      }
      final inc = incById[id];
      if (inc != null && !sameWireFields(prev, inc)) {
        changed = true;
        break;
      }
    }
  }
  return NodeMergeResult(kept, changed);
}