watchWithSub<U> method

Stream<List<WithChildren<T, U>>> watchWithSub<U>({
  1. required String subName,
  2. required Duration subDefaultExpiration,
  3. U subFromJson(
    1. Map<String, dynamic>
    )?,
  4. String? subTypeTag,
})

Live reactive terminal that joins each parent item matching this query with its children from a named sub-collection.

final stream = todos.query().where((t) => !t.obj.done)
    .watchWithSub<TodoNote>(
      subName: 'notes',
      subDefaultExpiration: const Duration(days: 365),
      subFromJson: TodoNote.fromJson,
      subTypeTag: 'TodoNote',
    );
// stream: Stream<List<WithChildren<Todo, TodoNote>>>

Re-emits on any parent update/delete that could affect the result set, and on any child update/delete within any of the current parents' subName sub-collections. The previous hand-rolled Map<parentId, List<child>> dance in consumer apps collapses to a single stream subscription with this.

The returned stream is single-subscription. Each parent held by the stream owns an internal .watch() on its sub-collection; those child subscriptions are cancelled automatically when the parent leaves the result set (via filter change or delete) and when the outer stream is cancelled.

This is a first-class terminal — implemented here rather than re-invented by every consumer. The children are the sub-collection's full default view.

Implementation

Stream<List<WithChildren<T, U>>> watchWithSub<U>({
  required String subName,
  required Duration subDefaultExpiration,
  U Function(Map<String, dynamic>)? subFromJson,
  String? subTypeTag,
}) {
  // Per-parent state. Key is `<owner>:<id>` — the (owner, id) pair
  // under which a CItem is globally unique.
  final childLatest = <String, List<CItem<U>>>{};
  final childSubs = <String, StreamSubscription<List<CItem<U>>>>{};
  List<CItem<T>> latestParents = const [];
  late final StreamController<List<WithChildren<T, U>>> ctrl;
  StreamSubscription<List<CItem<T>>>? parentSub;

  String keyOf(CItem<T> p) => '${p.owner}:${p.id}';

  void emit() {
    if (ctrl.isClosed) return;
    ctrl.add([
      for (final p in latestParents)
        WithChildren<T, U>(
          parent: p,
          children: List<CItem<U>>.from(childLatest[keyOf(p)] ?? const []),
        ),
    ]);
  }

  Future<void> onParents(List<CItem<T>> parents) async {
    latestParents = parents;
    final currentKeys = parents.map(keyOf).toSet();
    // Cancel subs for parents that left the result set.
    final leavers =
        childSubs.keys.where((k) => !currentKeys.contains(k)).toList();
    for (final k in leavers) {
      await childSubs.remove(k)?.cancel();
      childLatest.remove(k);
    }
    // Track whether we opened any new child sub. If we did, skip
    // the explicit emit below — each new sub's initial-fetch
    // emission already calls emit() through its listener, and
    // emitting from both paths produced duplicate snapshots
    // (consumers that did per-snapshot work, e.g. a TUI sending a
    // read-receipt on every new todo, were doing it twice).
    bool openedNewSub = false;
    for (final p in parents) {
      final k = keyOf(p);
      if (childSubs.containsKey(k)) continue;
      // Use the private internal entry point so we can thread the
      // parent collection's injected notification stream (if any)
      // through to the child sub-collection — required for tests
      // that drive both parent and child events from a single
      // controller. Production callers see only the public
      // [subCollection] verb, which has no notifications: param.
      final sub = _collection._subCollectionInternal<U>(
        parent: p,
        subName: subName,
        defaultExpiration: subDefaultExpiration,
        fromJson: subFromJson,
        typeTag: subTypeTag,
        notifications: _collection._injectedNotifications,
      );
      childSubs[k] = sub.query().watch().listen(
        (children) {
          childLatest[k] = children;
          emit();
        },
        onError: (Object e, StackTrace st) {
          if (!ctrl.isClosed) ctrl.addError(e, st);
        },
      );
      openedNewSub = true;
    }
    // No new subs opened (either only leavers, or the parent set
    // is unchanged) — emit now, otherwise no event is delivered
    // for the leaver removal / parent-set churn.
    if (!openedNewSub) {
      emit();
    }
  }

  ctrl = StreamController<List<WithChildren<T, U>>>(
    onListen: () {
      parentSub = watch().listen(
        (parents) => unawaited(onParents(parents)),
        onError: (Object e, StackTrace st) {
          if (!ctrl.isClosed) ctrl.addError(e, st);
        },
      );
    },
    onCancel: () async {
      await parentSub?.cancel();
      for (final s in childSubs.values) {
        await s.cancel();
      }
      childSubs.clear();
      childLatest.clear();
    },
  );
  return ctrl.stream;
}