Skip to main content

tilo_app/framework/
children.rs

1//! Child records: resources a record contains, each a record of its own
2//! (`tilo_resource::children`).
3//!
4//! A [`ChildRecord`] type names the record that lists it (its parent, the
5//! app's own record by default) and the container field there. The
6//! framework does the rest:
7//!
8//! - **Held where the parent is.** Every child the container lists (of this
9//!   kind) is a record of the app's store beside the parent
10//!   ([`RecordId::child`]), opened as it is listed, read as it changes
11//!   ([`Children::children`]), synced on its own route like every record of
12//!   the app.
13//! - **One door.** The container is published beside the app's resource
14//!   ([`ui_core::record::children_resource_id`]): every listed child with
15//!   its own actions, carried out on its record, checked against the
16//!   participant's roles in the workspace as the app's own actions are,
17//!   attributed to whoever invokes them and written into the child's
18//!   history.
19//! - **One lifecycle.** The container's `create` (a child with its first
20//!   fields, under a group, before a sibling), and on each child `delete`
21//!   (to the workspace's trash: its record and id kept, restorable), `restore`
22//!   and `move` (to another group or place: its reference moves, the child
23//!   and every reference to it stay). The app writes none of these verbs.
24//!
25//! An app declares a child type in
26//! [`ResourceApp::records`](super::ResourceApp::records)
27//! (`records.children::<Channel>()`) and reaches it from a view or surface
28//! with [`RecordEnv::children`](super::RecordEnv::children).
29
30use std::cell::{Cell, RefCell};
31use std::collections::BTreeMap;
32use std::rc::Rc;
33
34use serde::{Deserialize, Serialize};
35use tilo_actions::{
36    ActionContext, ActionError, ActionInvoker, ActionParam, ActionSpec, EncodedAction,
37    ExecutionLocus, OperationKind, ParamType,
38};
39use tilo_resource::children::{self as lifecycle, ChildList as _, ChildRef, Children as Container};
40use tilo_resource::{Atom, Edit, EditOp, Id, Reader, Resource, Segment};
41use ui_core::arriving::{Arrival, Arriving, Source};
42use ui_core::permissions::{PermissionScope, TreePath};
43use ui_core::record::{RecordCheckout, RecordId, RecordKind, RecordOpenError};
44use ui_core::widget::Edits;
45
46use super::chain::{CHAIN_ACTION_TYPE, WorkspaceRecords};
47use super::live::Live;
48use super::record::RecordDoor;
49
50/// The operation type a child's own actions are carried out under on the
51/// container's door: the action and the child it changes.
52pub(crate) const CHILD_ACTION_TYPE: &str = "tilo.app.child-action";
53/// The operation type the container's lifecycle is carried out under.
54pub(crate) const LIFECYCLE_TYPE: &str = "tilo.app.child-lifecycle";
55
56/// A listed child: its id, its place in the container and its state as a
57/// read, pending while its record is on its way from the workspace.
58pub type Listed<C> = (Id, ChildRef, Arriving<Rc<<C as Resource>::State>>);
59
60/// Why a listed child is not held.
61enum Unheld {
62    /// Its record is not on this device yet.
63    Missing,
64    /// It is here and does not read.
65    Unreadable(String),
66}
67
68impl From<String> for Unheld {
69    fn from(reason: String) -> Self {
70        Self::Unreadable(reason)
71    }
72}
73
74/// A resource whose records are children a record lists. See the
75/// module docs.
76pub trait ChildRecord: Resource<State: tilo_resource::Model + Default> {
77    /// The resource whose record lists these children.
78    type Parent: Resource;
79
80    /// The record that lists them: the app's own record in its workspace.
81    #[must_use]
82    fn parent_record() -> RecordId {
83        RecordId::workspace_default()
84    }
85
86    /// The container of the parent that lists them.
87    fn container(parent: &<Self::Parent as Resource>::State) -> &Container;
88
89    /// Where the container is in the parent's document: its field.
90    fn container_path() -> Vec<Segment>;
91
92    /// Refuse a place the parent does not have for one of these: under a
93    /// group of the wrong kind (a channel inside a channel). Allows any
94    /// listed group by default.
95    fn check_group(
96        parent: &<Self::Parent as Resource>::State,
97        group: Option<&Id>,
98    ) -> Result<(), String> {
99        let _ = (parent, group);
100        Ok(())
101    }
102}
103
104/// One lifecycle step, as the container's published actions carry it.
105#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
106#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
107pub(crate) enum Lifecycle {
108    /// A child with its first `fields` (its model's JSON form, partial),
109    /// under `group`, before the sibling `before`.
110    Create {
111        id: Id,
112        group: Option<Id>,
113        before: Option<Id>,
114        fields: serde_json::Value,
115    },
116    Delete {
117        child: Id,
118    },
119    Restore {
120        child: Id,
121    },
122    Move {
123        child: Id,
124        group: Option<Id>,
125        before: Option<Id>,
126    },
127}
128
129impl Lifecycle {
130    /// The published action carrying this step out on `door`, the
131    /// container's address: the resource it is on, its verb, the child it
132    /// concerns and its arguments.
133    fn invocation<C: Resource>(
134        &self,
135        door: &str,
136    ) -> (
137        String,
138        &'static str,
139        Id,
140        serde_json::Map<String, serde_json::Value>,
141    ) {
142        let text = |id: &Id| serde_json::Value::String(id.to_string());
143        let place = |group: &Option<Id>, before: &Option<Id>| {
144            let mut arguments = serde_json::Map::new();
145            if let Some(group) = group {
146                arguments.insert("group".into(), text(group));
147            }
148            if let Some(before) = before {
149                arguments.insert("before".into(), text(before));
150            }
151            arguments
152        };
153        match self {
154            Self::Create {
155                id,
156                group,
157                before,
158                fields,
159            } => {
160                let mut arguments = place(group, before);
161                arguments.insert("id".into(), text(id));
162                if !fields.is_null() {
163                    arguments.insert("fields".into(), fields.clone());
164                }
165                (door.to_owned(), lifecycle::CREATE, id.clone(), arguments)
166            }
167            Self::Delete { child } => (
168                child_resource_id::<C>(child),
169                lifecycle::DELETE,
170                child.clone(),
171                serde_json::Map::new(),
172            ),
173            Self::Restore { child } => (
174                child_resource_id::<C>(child),
175                lifecycle::RESTORE,
176                child.clone(),
177                serde_json::Map::new(),
178            ),
179            Self::Move {
180                child,
181                group,
182                before,
183            } => (
184                child_resource_id::<C>(child),
185                lifecycle::MOVE,
186                child.clone(),
187                place(group, before),
188            ),
189        }
190    }
191}
192
193/// One of a child's own actions, as the container's door carries it.
194#[derive(Serialize, Deserialize)]
195struct ChildAction {
196    child: Id,
197    action: EncodedAction,
198}
199
200/// The parent's state, as the lifecycle reads it where it is invoked.
201/// A field of the container's item `id`, in the parent's document.
202fn item_path<C: ChildRecord>(id: &Id, field: &str) -> Vec<Segment> {
203    let mut path = C::container_path();
204    path.push(Segment::Item(id.clone()));
205    path.push(Segment::Field(field.to_owned()));
206    path
207}
208
209/// How the container lists `child`, if it lists it as one of `C`.
210fn listed<'a, C: ChildRecord>(
211    parent: &'a <C::Parent as Resource>::State,
212    child: &Id,
213) -> Result<&'a ChildRef, ActionError> {
214    C::container(parent)
215        .get(child)
216        .filter(|listed| listed.kind.0 == C::KIND)
217        .ok_or_else(|| ActionError::Refused(format!("there is no such {}", C::LABEL)))
218}
219
220/// Whether `child` may sit under `group`: the container's rule, then `C`'s.
221fn check_place<C: ChildRecord>(
222    parent: &<C::Parent as Resource>::State,
223    child: &Id,
224    group: Option<&Id>,
225) -> Result<(), ActionError> {
226    lifecycle::check_group(C::container(parent), child, group).map_err(ActionError::Refused)?;
227    C::check_group(parent, group).map_err(ActionError::Refused)
228}
229
230type Parent<C> = Rc<dyn Fn() -> Rc<<<C as ChildRecord>::Parent as Resource>::State>>;
231
232/// The children of one kind a container lists, held for a window. See the
233/// [module docs](self).
234pub(crate) struct ChildrenState<C: ChildRecord> {
235    workspace: WorkspaceRecords,
236    parent: Parent<C>,
237    id: String,
238    kind: RecordKind,
239    held: RefCell<BTreeMap<Id, Live<C>>>,
240    /// Each listed child as a read: pending while its record is on its way
241    /// from the workspace, its state once it is here.
242    arrivals: RefCell<BTreeMap<Id, Arriving<Rc<C::State>>>>,
243    /// Why a listed child that is here does not read, by id.
244    unreadable: RefCell<BTreeMap<Id, String>>,
245    /// A child before anything was written to it: what the container
246    /// publishes for one whose record has not arrived.
247    empty: Rc<C::State>,
248    revision: Cell<u64>,
249    published: RefCell<Option<((u64, u64), tilo_actions::ResourceSpec)>>,
250    /// The door of the parent the window shows, which the lifecycle writes
251    /// through in the pass that lends it; `None` where the parent is held
252    /// in the store (a tool's, a test's).
253    parent_door: RefCell<Option<super::Dispatcher<C::Parent>>>,
254}
255
256impl<C: ChildRecord> ChildrenState<C> {
257    pub(crate) fn open(workspace: WorkspaceRecords, parent: Parent<C>) -> Option<Self> {
258        let mut doc = ui_core::automerge::AutoCommit::new();
259        C::initialize(&mut doc)
260            .map_err(|error| tracing::warn!(kind = C::KIND, %error, "child has no genesis"))
261            .ok()?;
262        let empty = Reader::<C>::read(&doc)
263            .map_err(|error| tracing::warn!(kind = C::KIND, %error, "child unreadable"))
264            .ok()?
265            .state()
266            .clone();
267        let state = Self {
268            workspace,
269            parent,
270            id: ui_core::record::children_resource_id(&C::parent_record(), C::KIND),
271            kind: RecordKind::new(C::KIND),
272            held: RefCell::default(),
273            arrivals: RefCell::default(),
274            unreadable: RefCell::default(),
275            empty,
276            revision: Cell::new(0),
277            published: RefCell::new(None),
278            parent_door: RefCell::new(None),
279        };
280        state.refresh();
281        Some(state)
282    }
283
284    pub(crate) fn id(&self) -> &str {
285        &self.id
286    }
287
288    /// Write the parent through `door`, the window's own: the parent is the
289    /// record it shows.
290    pub(crate) fn write_parent_through(&self, door: super::Dispatcher<C::Parent>) {
291        *self.parent_door.borrow_mut() = Some(door);
292    }
293
294    fn record(child: &Id) -> Result<RecordId, String> {
295        C::parent_record()
296            .child(child.as_str())
297            .ok_or_else(|| format!("`{child}` cannot name a record"))
298    }
299
300    fn moved(&self) {
301        self.revision.set(self.revision.get().wrapping_add(1));
302    }
303
304    /// The children of this kind the container lists, in order, the
305    /// trashed among them.
306    fn listed(&self) -> Vec<(Id, ChildRef)> {
307        let parent = (self.parent)();
308        C::container(&parent)
309            .iter()
310            .filter(|(_, child)| child.kind.0 == C::KIND)
311            .map(|(id, child)| (id.clone(), child.clone()))
312            .collect()
313    }
314
315    /// Every child outside the trash with its state as a read, in the
316    /// container's order: pending while its record is on its way from the
317    /// workspace.
318    pub(crate) fn children(&self) -> Vec<Listed<C>> {
319        self.refresh();
320        let arrivals = self.arrivals.borrow();
321        self.listed()
322            .into_iter()
323            .filter(|(_, child)| child.trashed.is_none())
324            .filter_map(|(id, child)| {
325                let arriving = arrivals.get(&id)?.clone();
326                Some((id, child, arriving))
327            })
328            .collect()
329    }
330
331    /// Bring each listed child's read to where its record stands: its
332    /// state once it is here, why it does not read, or on its way from the
333    /// workspace. Children no longer listed are let go.
334    fn settle(&self) {
335        let listed = self.listed();
336        let held = self.held.borrow();
337        let unreadable = self.unreadable.borrow();
338        let mut arrivals = self.arrivals.borrow_mut();
339        arrivals.retain(|id, _| listed.iter().any(|(listed, _)| listed == id));
340        for (id, _) in listed {
341            let now = match (held.get(&id), unreadable.get(&id)) {
342                (Some(live), _) => Arrival::Ready(live.get()),
343                (None, Some(reason)) => Arrival::Failed(reason.clone()),
344                (None, None) => Arrival::Pending {
345                    from: ui_core::workspace_sync::source(),
346                },
347            };
348            match arrivals.get(&id) {
349                None => {
350                    let arriving = Arriving::pending(Source::Local);
351                    arriving.stand(now);
352                    arrivals.insert(id, arriving);
353                }
354                Some(arriving) => {
355                    let same = arriving.with(|was| match (was, &now) {
356                        (Arrival::Ready(was), Arrival::Ready(now)) => Rc::ptr_eq(was, now),
357                        (Arrival::Failed(was), Arrival::Failed(now)) => was == now,
358                        (Arrival::Pending { from: was }, Arrival::Pending { from: now }) => {
359                            was == now
360                        }
361                        _ => false,
362                    });
363                    if !same {
364                        arriving.stand(now);
365                    }
366                }
367            }
368        }
369    }
370
371    /// The child `id`, listed or in the trash, when its record is here.
372    pub(crate) fn child(&self, id: &Id) -> Option<Rc<C::State>> {
373        self.refresh();
374        self.held.borrow().get(id).map(Live::get)
375    }
376
377    pub(crate) fn revision(&self) -> u64 {
378        self.refresh();
379        self.revision.get()
380    }
381
382    /// Follow every held child that changed and hold every child the
383    /// container lists that is here now. Whether anything moved.
384    pub(crate) fn refresh(&self) -> bool {
385        let mut moved = false;
386        for (id, live) in self.held.borrow().iter() {
387            let Ok(record) = Self::record(id) else {
388                continue;
389            };
390            match self.workspace.store.read(&record, |doc| live.sync(doc)) {
391                Some(Ok(true)) => moved = true,
392                Some(Err(error)) => {
393                    tracing::warn!(kind = C::KIND, %id, %error, "child unreadable");
394                }
395                _ => {}
396            }
397            let _ = live.take_change();
398        }
399        let missing: Vec<Id> = {
400            let held = self.held.borrow();
401            self.listed()
402                .into_iter()
403                .map(|(id, _)| id)
404                .filter(|id| !held.contains_key(id))
405                .collect()
406        };
407        for id in missing {
408            match self.hold(&id) {
409                Ok(()) => {
410                    self.unreadable.borrow_mut().remove(&id);
411                    moved = true;
412                }
413                // Not here yet: it is on its way from the workspace.
414                Err(Unheld::Missing) => {}
415                Err(Unheld::Unreadable(reason)) => {
416                    self.unreadable.borrow_mut().insert(id, reason);
417                }
418            }
419        }
420        if moved {
421            self.moved();
422        }
423        self.settle();
424        moved
425    }
426
427    /// Hold child `id`: read its record from this device, which asks the
428    /// workspace for it when it has not got it yet.
429    fn hold(&self, id: &Id) -> Result<(), Unheld> {
430        if self.held.borrow().contains_key(id) {
431            return Ok(());
432        }
433        let checkout = RecordCheckout::new(Self::record(id)?, self.kind.clone());
434        if !self.workspace.store.is_ready(&checkout.id) {
435            if !self.workspace.store.takes_writes() {
436                return Err(Unheld::Missing);
437            }
438            match self.workspace.store.try_open_record(&checkout) {
439                Ok(_) => {}
440                Err(RecordOpenError::MissingRecord | RecordOpenError::Busy) => {
441                    return Err(Unheld::Missing);
442                }
443                Err(error) => return Err(Unheld::Unreadable(error.to_string())),
444            }
445        }
446        Ok(self.follow(id)?)
447    }
448
449    fn follow(&self, id: &Id) -> Result<(), String> {
450        let record = Self::record(id)?;
451        let older = self
452            .workspace
453            .store
454            .read(&record, tilo_resource::evolution::needs_migrate::<C>)
455            .ok_or("the child is not open here")?;
456        if older {
457            if !self.workspace.store.takes_writes() {
458                return Err("migrated once the store takes writes".into());
459            }
460            self.workspace.store.migrate(&record, |doc| {
461                tilo_resource::evolution::migrate::<C>(doc).map_err(|error| error.to_string())
462            })?;
463        }
464        let reader = self
465            .workspace
466            .store
467            .read(&record, Reader::<C>::read)
468            .ok_or("the child is not open here")?
469            .map_err(|error| error.to_string())?;
470        self.held.borrow_mut().insert(id.clone(), Live::new(reader));
471        self.moved();
472        Ok(())
473    }
474
475    /// Make child `id`'s record with the child type's genesis.
476    fn start(&self, id: &Id) -> Result<(), String> {
477        let checkout = RecordCheckout::new(Self::record(id)?, self.kind.clone());
478        if !self.workspace.store.is_ready(&checkout.id) {
479            let initialize = |doc: &mut ui_core::automerge::AutoCommit| {
480                C::initialize(doc).map_err(|error| error.to_string())
481            };
482            match self.workspace.store.reserve_record(&checkout) {
483                Ok(mut reservation) => reservation
484                    .initialize(initialize)
485                    .map_err(|error| error.to_string())?,
486                // A store that keeps no canonical records (a test's)
487                // creates it the way it creates every record.
488                Err(RecordOpenError::IdentityMismatch) => {
489                    self.workspace
490                        .store
491                        .create_record(&checkout)
492                        .map_err(|error| error.to_string())?;
493                    self.workspace
494                        .store
495                        .with(&checkout.id, |doc| {
496                            initialize(doc)?;
497                            doc.commit();
498                            Ok::<(), String>(())
499                        })
500                        .ok_or("the new child is not open")??;
501                }
502                Err(error) => return Err(error.to_string()),
503            }
504        }
505        self.follow(id)
506    }
507
508    /// Whether `invoker` may take a step `kind` on `verb` of this
509    /// container, as the workspace's roles say.
510    fn may(&self, verb: &str, kind: tilo_actions::ActionKind) -> bool {
511        self.workspace
512            .permissions
513            .as_ref()
514            .is_none_or(|permissions| {
515                permissions.check(&PermissionScope::Action {
516                    node: tilo_actions::action_node(
517                        &TreePath::root(),
518                        self.workspace.app_id,
519                        C::KIND,
520                        &self.id,
521                        None,
522                        verb,
523                    ),
524                    kind,
525                })
526            })
527    }
528
529    /// Write `edits` into the parent, as `invoker`, recorded in its
530    /// history: the container's lifecycle. The parent the window shows is
531    /// written through its own door in the pass that lends it (`live`);
532    /// one held in the store, as a tool holds it, there.
533    fn edit_parent(
534        &self,
535        verb: &str,
536        edits: Vec<Edit<C::Parent>>,
537        invoker: &ActionInvoker,
538        mut live: Option<&mut Edits<'_>>,
539    ) -> Result<(), ActionError> {
540        let record = C::parent_record();
541        let door = self.parent_door.borrow().clone();
542        for edit in edits {
543            let action = <C::Parent as Resource>::edit_action(edit)
544                .ok_or_else(|| ActionError::Refused(format!("{} take no field edits", C::LABEL)))?;
545            if let Some(door) = &door {
546                let live = live.as_mut().ok_or_else(|| {
547                    ActionError::Refused(format!("the record listing {verb} is not lent here"))
548                })?;
549                door.apply_now_for(live, &action, invoker)?;
550                continue;
551            }
552            let encoded = tilo_resource::encode::<C::Parent>(&action)?;
553            let context = ActionContext::for_resource(&self.id, C::KIND, C::LABEL)
554                .with_app(self.workspace.app_id, self.workspace.app_title)
555                .with_invoker(invoker.clone())
556                .with_change(tilo_actions::Action::change_kind(&action));
557            self.workspace
558                .store
559                .with_history(&record, |doc, history| {
560                    let writer = ui_core::bound_dispatch::document_writer(&invoker.actor, doc);
561                    let applied = tilo_actions::apply_local_with_context(
562                        doc,
563                        &action,
564                        encoded,
565                        Some(&writer),
566                        context,
567                    )
568                    .map(drop)
569                    .map_err(|error| ActionError::Refused(error.to_string()));
570                    history.rebase(doc);
571                    applied
572                })
573                .ok_or_else(|| {
574                    ActionError::Refused(format!("the record listing {verb} is not open here"))
575                })??;
576            if let Err(error) = self.workspace.store.schedule_committed_history(&record) {
577                tracing::error!(%error, "an edited child record could not be saved");
578            }
579        }
580        self.refresh();
581        self.moved();
582        Ok(())
583    }
584
585    /// Carry out one lifecycle step for `invoker`. The child it concerns.
586    pub(crate) fn lifecycle(
587        &self,
588        step: &Lifecycle,
589        invoker: &ActionInvoker,
590        now_ms: i64,
591        live: Option<&mut Edits<'_>>,
592    ) -> Result<Id, ActionError> {
593        use tilo_actions::ActionKind;
594        let verb = match step {
595            Lifecycle::Create { .. } => lifecycle::CREATE,
596            Lifecycle::Delete { .. } => lifecycle::DELETE,
597            Lifecycle::Restore { .. } => lifecycle::RESTORE,
598            Lifecycle::Move { .. } => lifecycle::MOVE,
599        };
600        if !self.may(verb, ActionKind::Undoable) {
601            return Err(ActionError::Refused(format!(
602                "you may not {verb} these {}",
603                C::LABEL.to_lowercase()
604            )));
605        }
606        let parent = (self.parent)();
607        match step {
608            Lifecycle::Create {
609                id,
610                group,
611                before,
612                fields,
613            } => self.create_child(
614                &parent,
615                id,
616                group.as_ref(),
617                before.as_ref(),
618                fields,
619                invoker,
620                live,
621            ),
622            Lifecycle::Delete { child } => self.delete_child(&parent, child, invoker, now_ms, live),
623            Lifecycle::Restore { child } => self.restore_child(&parent, child, invoker, live),
624            Lifecycle::Move {
625                child,
626                group,
627                before,
628            } => self.move_child(
629                &parent,
630                child,
631                group.as_ref(),
632                before.as_ref(),
633                invoker,
634                live,
635            ),
636        }
637    }
638
639    /// A new child `id` under `group`, before `before`, with its first fields.
640    fn create_child(
641        &self,
642        parent: &<C::Parent as Resource>::State,
643        id: &Id,
644        group: Option<&Id>,
645        before: Option<&Id>,
646        fields: &serde_json::Value,
647        invoker: &ActionInvoker,
648        live: Option<&mut Edits<'_>>,
649    ) -> Result<Id, ActionError> {
650        let container = C::container(parent);
651        if container.contains(id) {
652            return Err(ActionError::Refused("that child exists already".into()));
653        }
654        check_place::<C>(parent, id, group)?;
655        let first = first_fields::<C>(fields).map_err(ActionError::Refused)?;
656        self.start(id).map_err(ActionError::Refused)?;
657        if !first.is_empty() {
658            self.edit_child(id, first, invoker)?;
659        }
660        let at = lifecycle::place(container, before);
661        let reference = ChildRef {
662            kind: Atom(C::KIND.to_owned()),
663            group: group.cloned(),
664            trashed: None,
665        };
666        self.edit_parent(
667            lifecycle::CREATE,
668            vec![Edit::new(
669                C::container_path(),
670                EditOp::InsertItem {
671                    id: Some(id.clone()),
672                    at: at.into(),
673                    value: tilo_resource::Model::to_json(&reference),
674                },
675            )],
676            invoker,
677            live,
678        )?;
679        Ok(id.clone())
680    }
681
682    /// `child` to the trash, whole.
683    fn delete_child(
684        &self,
685        parent: &<C::Parent as Resource>::State,
686        child: &Id,
687        invoker: &ActionInvoker,
688        now_ms: i64,
689        live: Option<&mut Edits<'_>>,
690    ) -> Result<Id, ActionError> {
691        if listed::<C>(parent, child)?.trashed.is_some() {
692            return Ok(child.clone());
693        }
694        let trashed = lifecycle::Trashed { at: now_ms };
695        self.edit_parent(
696            lifecycle::DELETE,
697            vec![Edit::new(
698                item_path::<C>(child, "trashed"),
699                EditOp::Set {
700                    value: tilo_resource::Model::to_json(&Some(trashed)),
701                },
702            )],
703            invoker,
704            live,
705        )?;
706        Ok(child.clone())
707    }
708
709    /// `child` back from the trash.
710    fn restore_child(
711        &self,
712        parent: &<C::Parent as Resource>::State,
713        child: &Id,
714        invoker: &ActionInvoker,
715        live: Option<&mut Edits<'_>>,
716    ) -> Result<Id, ActionError> {
717        let listed = listed::<C>(parent, child)?;
718        if listed.trashed.is_none() {
719            return Ok(child.clone());
720        }
721        // Its group may have gone to the trash meanwhile: it comes back at
722        // the top then.
723        let mut edits = vec![Edit::new(
724            item_path::<C>(child, "trashed"),
725            EditOp::Set {
726                value: serde_json::Value::Null,
727            },
728        )];
729        if listed
730            .group
731            .as_ref()
732            .is_some_and(|group| !C::container(parent).lists(group))
733        {
734            edits.push(Edit::new(
735                item_path::<C>(child, "group"),
736                EditOp::Set {
737                    value: serde_json::Value::Null,
738                },
739            ));
740        }
741        self.edit_parent(lifecycle::RESTORE, edits, invoker, live)?;
742        Ok(child.clone())
743    }
744
745    /// `child` under `group`, before `before`.
746    fn move_child(
747        &self,
748        parent: &<C::Parent as Resource>::State,
749        child: &Id,
750        group: Option<&Id>,
751        before: Option<&Id>,
752        invoker: &ActionInvoker,
753        live: Option<&mut Edits<'_>>,
754    ) -> Result<Id, ActionError> {
755        listed::<C>(parent, child)?;
756        check_place::<C>(parent, child, group)?;
757        let container = C::container(parent);
758        let at = lifecycle::place(container, before);
759        let from = container.position(child).unwrap_or_default();
760        // Taken out first, the place after it is one less.
761        let at = if at > from { at.saturating_sub(1) } else { at };
762        let edits = vec![
763            Edit::new(
764                item_path::<C>(child, "group"),
765                EditOp::Set {
766                    value: tilo_resource::Model::to_json(&group.cloned()),
767                },
768            ),
769            Edit::new(
770                C::container_path(),
771                EditOp::MoveTo {
772                    id: child.clone(),
773                    at: at.into(),
774                },
775            ),
776        ];
777        self.edit_parent(lifecycle::MOVE, edits, invoker, live)?;
778        Ok(child.clone())
779    }
780
781    /// Write the first fields of a new child into its record.
782    fn edit_child(
783        &self,
784        child: &Id,
785        edits: Vec<Edit<C>>,
786        invoker: &ActionInvoker,
787    ) -> Result<(), ActionError> {
788        for edit in edits {
789            let action = C::edit_action(edit)
790                .ok_or_else(|| ActionError::Refused(format!("{} take no field edits", C::LABEL)))?;
791            self.write(child, &action, invoker)?;
792        }
793        Ok(())
794    }
795
796    /// Apply `action`, one of child `child`'s own, for `invoker`: checked
797    /// against what they may do in the workspace, attributed to them and
798    /// written into the child's history.
799    pub(crate) fn apply_to(
800        &self,
801        child: &Id,
802        action: &C::Action,
803        invoker: &ActionInvoker,
804    ) -> Result<Vec<String>, ActionError> {
805        let need = tilo_resource::need_of::<C>(action);
806        if let Some(permissions) = &self.workspace.permissions {
807            let node = TreePath::root()
808                .app(self.workspace.app_id)
809                .kind(C::KIND)
810                .resource(self.id.clone())
811                .field(need.field.as_deref().unwrap_or_default());
812            if !permissions.check(&PermissionScope::Action {
813                node,
814                kind: need.kind,
815            }) {
816                return Err(ActionError::Refused(format!(
817                    "you may not do this to these {}",
818                    C::LABEL.to_lowercase()
819                )));
820            }
821        }
822        self.hold(child).map_err(|unheld| {
823            ActionError::Refused(match unheld {
824                Unheld::Missing => "it has not arrived on this device yet".to_owned(),
825                Unheld::Unreadable(reason) => reason,
826            })
827        })?;
828        self.write(child, action, invoker)
829    }
830
831    fn write(
832        &self,
833        child: &Id,
834        action: &C::Action,
835        invoker: &ActionInvoker,
836    ) -> Result<Vec<String>, ActionError> {
837        let encoded = tilo_resource::encode::<C>(action)?;
838        let context = ActionContext::for_resource(child_resource_id::<C>(child), C::KIND, C::LABEL)
839            .with_app(self.workspace.app_id, self.workspace.app_title)
840            .with_invoker(invoker.clone())
841            .with_change(tilo_actions::Action::change_kind(action));
842        let record = Self::record(child).map_err(ActionError::Refused)?;
843        let created = self
844            .workspace
845            .store
846            .with_history(&record, |doc, history| {
847                let before = doc.get_heads();
848                let writer = ui_core::bound_dispatch::document_writer(&invoker.actor, doc);
849                let applied = tilo_actions::apply_local_with_context(
850                    doc,
851                    action,
852                    encoded,
853                    Some(&writer),
854                    context,
855                )
856                .map(|envelope| {
857                    ui_core::bound_dispatch::minted_items(doc, C::KIND, &envelope, &before)
858                })
859                .map_err(|error| ActionError::Refused(error.to_string()));
860                history.rebase(doc);
861                applied
862            })
863            .ok_or_else(|| ActionError::Refused("the child is not open here".into()))??;
864        if let Err(error) = self.workspace.store.schedule_committed_history(&record) {
865            tracing::error!(%error, "a created child record could not be saved");
866        }
867        self.refresh();
868        self.moved();
869        Ok(created)
870    }
871
872    /// The container as it is published: `create`, and every child with
873    /// its own actions, `delete` and `move` (a trashed one with `restore`),
874    /// carried out on the container's door.
875    pub(crate) fn publish(&self) -> tilo_actions::ResourceSpec {
876        let revision = (self.revision(), ui_core::i18n::I18nManager::revision());
877        if let Some((at, published)) = self.published.borrow().as_ref()
878            && *at == revision
879        {
880            return published.clone();
881        }
882        let mut resource = tilo_actions::ResourceSpec::new(
883            self.id.clone(),
884            format!("{}.children", C::KIND),
885            C::LABEL,
886        )
887        .with_action(self.route(create_spec::<C>()));
888        let held = self.held.borrow();
889        for (id, child) in self.listed() {
890            let state = held
891                .get(&id)
892                .map_or_else(|| Rc::clone(&self.empty), Live::get);
893            let address = child_resource_id::<C>(&id);
894            let label = tilo_resource::Model::label(&*state).unwrap_or_else(|| id.to_string());
895            let mut published = if child.trashed.is_some() {
896                tilo_actions::ResourceSpec::new(address, C::KIND, label)
897                    .with_action(self.route(step_spec(lifecycle::RESTORE, "Restore", &id)))
898            } else {
899                let own = tilo_resource::publish::<C>(&address, &label, &state);
900                route_child(own, &self.id, &id)
901                    .with_action(self.route(step_spec(lifecycle::DELETE, "Move to Trash", &id)))
902                    .with_action(self.route(move_spec(&id)))
903            };
904            published.actions.dedup_by(|a, b| a.id == b.id);
905            resource = resource.with_child(published);
906        }
907        drop(held);
908        *self.published.borrow_mut() = Some((revision, resource.clone()));
909        resource
910    }
911
912    /// `spec` carried out on this container's door.
913    fn route(&self, spec: ActionSpec) -> ActionSpec {
914        super::record::route_operation(spec, CHAIN_ACTION_TYPE, &self.id)
915    }
916}
917
918/// The address child `id` of `C` is published under: its kind and its id,
919/// wherever it is listed, so moving it keeps every reference to it.
920#[must_use]
921pub fn child_resource_id<C: Resource>(id: &Id) -> String {
922    format!("{}/{id}", C::KIND)
923}
924
925/// The edits that give a new `C` its first `fields` (its JSON form, any of
926/// its fields), each set on its own.
927fn first_fields<C: ChildRecord>(fields: &serde_json::Value) -> Result<Vec<Edit<C>>, String> {
928    let fields = match fields {
929        serde_json::Value::Null => return Ok(Vec::new()),
930        serde_json::Value::Object(fields) => fields,
931        _ => return Err("a new child's fields are an object".into()),
932    };
933    // The whole value has to read as one of these, so nothing unknown or
934    // ill-typed is written.
935    let mut whole = tilo_resource::Model::to_json(&C::State::default());
936    if let serde_json::Value::Object(defaults) = &mut whole {
937        for (key, value) in fields {
938            if !defaults.contains_key(key) {
939                return Err(format!("{} have no `{key}`", C::LABEL));
940            }
941            defaults.insert(key.clone(), value.clone());
942        }
943    }
944    <C::State as tilo_resource::Model>::from_json(&whole).map_err(|error| error.to_string())?;
945    Ok(fields
946        .iter()
947        .map(|(key, value)| {
948            Edit::new(
949                vec![Segment::Field(key.clone())],
950                EditOp::Set {
951                    value: value.clone(),
952                },
953            )
954        })
955        .collect())
956}
957
958/// `resource`'s document actions, and those of its children, carried out on
959/// child `child`'s record through the container's door `door`.
960fn route_child(
961    mut resource: tilo_actions::ResourceSpec,
962    door: &str,
963    child: &Id,
964) -> tilo_actions::ResourceSpec {
965    resource.actions = std::mem::take(&mut resource.actions)
966        .into_iter()
967        .filter(|spec| spec.kind == OperationKind::Document)
968        .map(|spec| wrap_child(spec, door, child))
969        .collect();
970    resource.children = std::mem::take(&mut resource.children)
971        .into_iter()
972        .map(|nested| route_child(nested, door, child))
973        .collect();
974    resource
975}
976
977/// `spec` carried out on child `child` through the container's door: one
978/// routing, whose operation names the door and whose action the child.
979fn wrap_child(spec: ActionSpec, door: &str, child: &Id) -> ActionSpec {
980    let child = child.clone();
981    let door = door.to_owned();
982    spec.routed(CHAIN_ACTION_TYPE, move |action| {
983        let routed = EncodedAction::encode_operation(
984            CHILD_ACTION_TYPE,
985            &ChildAction {
986                child: child.clone(),
987                action,
988            },
989        )
990        .and_then(|action| {
991            EncodedAction::encode_operation(
992                CHAIN_ACTION_TYPE,
993                &super::record::RecordAction {
994                    record: door.clone(),
995                    action,
996                },
997            )
998        });
999        routed.unwrap_or_else(|error| invalid(&error.to_string()))
1000    })
1001}
1002
1003fn invalid(error: &str) -> EncodedAction {
1004    EncodedAction {
1005        type_id: tilo_actions::resource::INVALID_ARGUMENTS_TYPE.to_owned(),
1006        payload: error.as_bytes().to_vec(),
1007    }
1008}
1009
1010fn encode_step(step: &Lifecycle) -> EncodedAction {
1011    EncodedAction::encode_operation(LIFECYCLE_TYPE, step)
1012        .unwrap_or_else(|error| invalid(&error.to_string()))
1013}
1014
1015fn text_arg(args: &tilo_actions::ActionArgs, name: &str) -> Option<Id> {
1016    match args.get(name) {
1017        Some(tilo_actions::ArgValue::Text(text) | tilo_actions::ArgValue::Resource(text))
1018            if !text.is_empty() =>
1019        {
1020            // A resource address names the child by its last segment.
1021            Some(Id::from_raw(text.rsplit('/').next().unwrap_or(text)))
1022        }
1023        _ => None,
1024    }
1025}
1026
1027/// The container's `create`: a child with its first fields, under a group,
1028/// before a sibling.
1029fn create_spec<C: Resource>() -> ActionSpec {
1030    ActionSpec::typed(
1031        lifecycle::CREATE,
1032        format!("New {}", C::LABEL.to_lowercase()),
1033        "fields",
1034        ExecutionLocus::ClientLocal,
1035        vec![
1036            ActionParam::optional("fields", ParamType::Object, "Its first fields"),
1037            ActionParam::optional("group", ParamType::Text, "The child to list it under"),
1038            ActionParam::optional("before", ParamType::Text, "The child to place it before"),
1039            ActionParam::optional(
1040                "id",
1041                ParamType::Text,
1042                "The id it is made under; a new one without",
1043            ),
1044        ],
1045        |args| {
1046            let fields = match args.get("fields") {
1047                Some(tilo_actions::ArgValue::Object(fields)) => {
1048                    serde_json::Value::Object(fields.clone())
1049                }
1050                _ => serde_json::Value::Null,
1051            };
1052            encode_step(&Lifecycle::Create {
1053                id: text_arg(args, "id")
1054                    .unwrap_or_else(|| Id::from_raw(tilo_resource::fresh_uuid().to_string())),
1055                group: text_arg(args, "group"),
1056                before: text_arg(args, "before"),
1057                fields,
1058            })
1059        },
1060    )
1061}
1062
1063/// A child's `delete` or `restore`.
1064fn step_spec(verb: &'static str, label: &str, child: &Id) -> ActionSpec {
1065    let child = child.clone();
1066    ActionSpec::typed(
1067        verb,
1068        label,
1069        "",
1070        ExecutionLocus::ClientLocal,
1071        Vec::new(),
1072        move |_| {
1073            let child = child.clone();
1074            encode_step(&match verb {
1075                lifecycle::DELETE => Lifecycle::Delete { child },
1076                _ => Lifecycle::Restore { child },
1077            })
1078        },
1079    )
1080}
1081
1082/// A child's `move`: under another group, before another sibling.
1083fn move_spec(child: &Id) -> ActionSpec {
1084    let child = child.clone();
1085    ActionSpec::typed(
1086        lifecycle::MOVE,
1087        "Move",
1088        "group",
1089        ExecutionLocus::ClientLocal,
1090        vec![
1091            ActionParam::optional("group", ParamType::Text, "The child to list it under"),
1092            ActionParam::optional("before", ParamType::Text, "The child to place it before"),
1093        ],
1094        move |args| {
1095            encode_step(&Lifecycle::Move {
1096                child: child.clone(),
1097                group: text_arg(args, "group"),
1098                before: text_arg(args, "before"),
1099            })
1100        },
1101    )
1102}
1103
1104impl<C: ChildRecord> RecordDoor for ChildrenState<C> {
1105    fn id(&self) -> &str {
1106        &self.id
1107    }
1108
1109    fn editable(&self) -> bool {
1110        true
1111    }
1112
1113    fn revision(&self) -> u64 {
1114        ChildrenState::revision(self)
1115    }
1116
1117    fn publish(&self) -> tilo_actions::ResourceSpec {
1118        ChildrenState::publish(self)
1119    }
1120
1121    fn apply_encoded(&self, action: &EncodedAction, invoker: &ActionInvoker) -> Result<(), String> {
1122        self.apply_answering(action, invoker).map(drop)
1123    }
1124
1125    fn apply_in(
1126        &self,
1127        action: &EncodedAction,
1128        invoker: &ActionInvoker,
1129        live: &mut Edits<'_>,
1130    ) -> Result<tilo_actions::ActionOutput, String> {
1131        self.answer(action, invoker, Some(live))
1132    }
1133
1134    fn apply_answering(
1135        &self,
1136        action: &EncodedAction,
1137        invoker: &ActionInvoker,
1138    ) -> Result<tilo_actions::ActionOutput, String> {
1139        self.answer(action, invoker, None)
1140    }
1141}
1142
1143impl<C: ChildRecord> ChildrenState<C> {
1144    /// Carry out one published action of the container for `invoker`, the
1145    /// window lending the parent it shows as `live`.
1146    fn answer(
1147        &self,
1148        action: &EncodedAction,
1149        invoker: &ActionInvoker,
1150        live: Option<&mut Edits<'_>>,
1151    ) -> Result<tilo_actions::ActionOutput, String> {
1152        match action.type_id.as_str() {
1153            LIFECYCLE_TYPE => {
1154                let step: Lifecycle = tilo_actions::decode_json(&action.payload)
1155                    .map_err(|error| error.to_string())?;
1156                let child = self
1157                    .lifecycle(&step, invoker, tilo_resource::origin_now_ms(), live)
1158                    .map_err(|error| error.to_string())?;
1159                Ok(tilo_actions::ActionOutput {
1160                    value: None,
1161                    resources: vec![child_resource_id::<C>(&child)],
1162                })
1163            }
1164            CHILD_ACTION_TYPE => {
1165                let request: ChildAction = tilo_actions::decode_json(&action.payload)
1166                    .map_err(|error| error.to_string())?;
1167                if request.action.type_id != C::ACTION_TYPE {
1168                    return Err(format!(
1169                        "{} take no `{}` action",
1170                        C::LABEL,
1171                        request.action.type_id
1172                    ));
1173                }
1174                let typed: C::Action = tilo_actions::decode_json(&request.action.payload)
1175                    .map_err(|error| error.to_string())?;
1176                let resources = self
1177                    .apply_to(&request.child, &typed, invoker)
1178                    .map_err(|error| error.to_string())?;
1179                Ok(tilo_actions::ActionOutput {
1180                    value: None,
1181                    resources,
1182                })
1183            }
1184            other => Err(format!("{} take no `{other}` action", C::LABEL)),
1185        }
1186    }
1187}
1188
1189/// The children of `C` a record lists, held for a view or surface. See the
1190/// module docs.
1191pub struct Children<C: ChildRecord> {
1192    state: Rc<ChildrenState<C>>,
1193    me: String,
1194    /// The door of the parent the view shows, which the lifecycle a view
1195    /// asks for goes through.
1196    dispatch: Option<super::Dispatcher<C::Parent>>,
1197}
1198
1199impl<C: ChildRecord> Clone for Children<C> {
1200    fn clone(&self) -> Self {
1201        Self {
1202            state: Rc::clone(&self.state),
1203            me: self.me.clone(),
1204            dispatch: self.dispatch.clone(),
1205        }
1206    }
1207}
1208
1209impl<C: ChildRecord> Children<C> {
1210    pub(crate) fn new(state: Rc<ChildrenState<C>>, me: String) -> Self {
1211        Self {
1212            state,
1213            me,
1214            dispatch: None,
1215        }
1216    }
1217
1218    /// These, submitting through `parent`'s door for the view that holds
1219    /// them: the door of the parent it shows.
1220    pub(crate) fn dispatching(mut self, parent: &super::Dispatcher<C::Parent>) -> Self {
1221        self.state.write_parent_through(parent.clone());
1222        self.dispatch = Some(parent.clone());
1223        self
1224    }
1225
1226    /// The address the container is published under.
1227    #[must_use]
1228    pub fn id(&self) -> &str {
1229        self.state.id()
1230    }
1231
1232    /// Every child outside the trash, with its place and its state as a
1233    /// read, in order: a child whose record is on its way from the workspace is
1234    /// pending. Show it with [`ViewCx::arriving`](super::ViewCx::arriving).
1235    #[must_use]
1236    pub fn children(&self) -> Vec<Listed<C>> {
1237        self.state.children()
1238    }
1239
1240    /// Child `id`, listed or in the trash, when its record is here.
1241    #[must_use]
1242    pub fn child(&self, id: &Id) -> Option<Rc<C::State>> {
1243        self.state.child(id)
1244    }
1245
1246    /// Increases whenever a child or the container changed.
1247    #[must_use]
1248    pub fn revision(&self) -> u64 {
1249        self.state.revision()
1250    }
1251
1252    /// Make a child with its first `fields` (its JSON form, partial) under
1253    /// `group`, before `before`, as the window's person. Its id.
1254    ///
1255    /// # Errors
1256    /// As the door refuses `create`.
1257    pub fn create(
1258        &self,
1259        fields: serde_json::Value,
1260        group: Option<&Id>,
1261        before: Option<&Id>,
1262    ) -> Result<Id, ActionError> {
1263        self.step(&Lifecycle::Create {
1264            id: Id::from_raw(tilo_resource::fresh_uuid().to_string()),
1265            group: group.cloned(),
1266            before: before.cloned(),
1267            fields,
1268        })
1269    }
1270
1271    /// Put child `id` in the workspace's trash, as the window's person.
1272    ///
1273    /// # Errors
1274    /// As the door refuses `delete`.
1275    pub fn delete(&self, id: &Id) -> Result<(), ActionError> {
1276        self.step(&Lifecycle::Delete { child: id.clone() })
1277            .map(drop)
1278    }
1279
1280    /// Take child `id` out of the trash, as the window's person.
1281    ///
1282    /// # Errors
1283    /// As the door refuses `restore`.
1284    pub fn restore(&self, id: &Id) -> Result<(), ActionError> {
1285        self.step(&Lifecycle::Restore { child: id.clone() })
1286            .map(drop)
1287    }
1288
1289    /// Move child `id` under `group`, before `before`, as the window's
1290    /// person.
1291    ///
1292    /// # Errors
1293    /// As the door refuses `move`.
1294    pub fn move_to(
1295        &self,
1296        id: &Id,
1297        group: Option<&Id>,
1298        before: Option<&Id>,
1299    ) -> Result<(), ActionError> {
1300        self.step(&Lifecycle::Move {
1301            child: id.clone(),
1302            group: group.cloned(),
1303            before: before.cloned(),
1304        })
1305        .map(drop)
1306    }
1307
1308    /// Apply one of child `id`'s own actions, as the window's person.
1309    ///
1310    /// # Errors
1311    /// As the door refuses it.
1312    pub fn apply(&self, id: &Id, action: &C::Action) -> Result<(), ActionError> {
1313        self.state
1314            .apply_to(id, action, &ActionInvoker::actor(&self.me))
1315            .map(drop)
1316    }
1317
1318    /// Carry out `step` as the window's person: in a view, through the
1319    /// door, which writes the parent the window shows in the pass that
1320    /// lends it (a refusal is the view's dispatcher's to tell); held
1321    /// outside a window, now.
1322    fn step(&self, step: &Lifecycle) -> Result<Id, ActionError> {
1323        let Some(parent) = &self.dispatch else {
1324            return self.state.lifecycle(
1325                step,
1326                &ActionInvoker::actor(&self.me),
1327                tilo_resource::origin_now_ms(),
1328                None,
1329            );
1330        };
1331        let (resource, verb, child, arguments) = step.invocation::<C>(self.state.id());
1332        parent.invoke_at(
1333            tilo_actions::door::ActionAddress::new(resource, verb),
1334            arguments,
1335        );
1336        Ok(child)
1337    }
1338
1339    /// The container published as the door publishes it, for a test.
1340    #[must_use]
1341    pub fn published(&self) -> tilo_actions::ResourceSpec {
1342        self.state.publish()
1343    }
1344
1345    /// Carry out a published action of the container, as the door does:
1346    /// what an agent invoking it reaches.
1347    ///
1348    /// # Errors
1349    /// As the door refuses it.
1350    pub fn invoke_published(
1351        &self,
1352        action: &EncodedAction,
1353        invoker: &ActionInvoker,
1354    ) -> Result<tilo_actions::ActionOutput, String> {
1355        let request: super::record::RecordAction =
1356            tilo_actions::decode_json(&action.payload).map_err(|error| error.to_string())?;
1357        if request.record != self.state.id() {
1358            return Err(format!("`{}` is another record", request.record));
1359        }
1360        self.state.apply_answering(&request.action, invoker)
1361    }
1362
1363    /// Children held outside any host, for a tool or a test: in `store`,
1364    /// which holds the parent's record, edited as `me`, who may do
1365    /// anything, the parent read from `parent`. `None` when `C` does not
1366    /// read as a child.
1367    #[must_use]
1368    pub fn detached_in(
1369        store: ui_core::record::RecordStore,
1370        app_id: &'static str,
1371        me: &str,
1372        parent: impl Fn() -> Rc<<C::Parent as Resource>::State> + 'static,
1373    ) -> Option<Self> {
1374        let workspace = WorkspaceRecords {
1375            store,
1376            me: me.to_owned(),
1377            permissions: None,
1378            app_id,
1379            app_title: app_id,
1380        };
1381        let state = Rc::new(ChildrenState::<C>::open(workspace, Rc::new(parent))?);
1382        Some(Self::new(state, me.to_owned()))
1383    }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388    #![allow(
1389        clippy::unwrap_used,
1390        clippy::expect_used,
1391        clippy::indexing_slicing,
1392        reason = "in a test these are the assertion"
1393    )]
1394
1395    use super::*;
1396    use tilo_resource::{Model, resource};
1397    use ui_core::record::RecordStore;
1398
1399    #[derive(Model, Default, Debug, Clone, PartialEq)]
1400    pub struct Workspace {
1401        pub entries: Container,
1402    }
1403
1404    #[resource(kind = "test.workspace", label = "Workspace")]
1405    impl Workspace {
1406        /// Rename nothing.
1407        #[action]
1408        #[expect(
1409            clippy::unused_self,
1410            clippy::needless_pass_by_ref_mut,
1411            reason = "an action takes the state it acts on"
1412        )]
1413        pub fn touch(&mut self) {}
1414    }
1415
1416    #[derive(Model, Default, Debug, Clone, PartialEq)]
1417    pub struct Topic {
1418        #[model(label)]
1419        pub name: String,
1420        pub note: String,
1421    }
1422
1423    #[resource(kind = "test.topic", label = "Topics")]
1424    impl Topic {
1425        /// Note.
1426        #[action]
1427        pub fn jot(&mut self, text: String) {
1428            self.note = text;
1429        }
1430    }
1431
1432    impl ChildRecord for Topic {
1433        type Parent = Workspace;
1434        fn container(parent: &Workspace) -> &Container {
1435            &parent.entries
1436        }
1437        fn container_path() -> Vec<Segment> {
1438            vec![Segment::Field("entries".into())]
1439        }
1440    }
1441
1442    /// A workspace's record in a store of its own, and its topics held over it.
1443    fn workspace() -> (RecordStore, Children<Topic>) {
1444        let store = RecordStore::in_memory();
1445        let parent = RecordCheckout::new(
1446            RecordId::workspace_default(),
1447            RecordKind::new(Workspace::KIND),
1448        );
1449        store.create_record(&parent).unwrap();
1450        store
1451            .with(&parent.id, |doc| {
1452                Workspace::initialize(doc).unwrap();
1453                doc.commit();
1454            })
1455            .unwrap();
1456        let read = store.clone();
1457        let children =
1458            Children::<Topic>::detached_in(store.clone(), "so.tilo.test", "ana", move || {
1459                read.read(&RecordId::workspace_default(), |doc| {
1460                    Reader::<Workspace>::read(doc).unwrap().state().clone()
1461                })
1462                .unwrap()
1463            })
1464            .unwrap();
1465        (store, children)
1466    }
1467
1468    fn order(children: &Children<Topic>) -> Vec<(String, Option<String>)> {
1469        children
1470            .children()
1471            .into_iter()
1472            .map(|(_, child, state)| {
1473                let group = child.group.map(|group| {
1474                    children
1475                        .child(&group)
1476                        .map_or_else(|| group.to_string(), |topic| topic.name.clone())
1477                });
1478                let name = state.with(|state| state.ready().map(|topic| topic.name.clone()));
1479                (name.expect("every child here"), group)
1480            })
1481            .collect()
1482    }
1483
1484    #[test]
1485    fn a_child_is_a_record_of_its_own_and_moving_it_keeps_its_identity() {
1486        let (store, children) = workspace();
1487        let design = children
1488            .create(serde_json::json!({ "name": "design" }), None, None)
1489            .unwrap();
1490        let ui = children
1491            .create(serde_json::json!({ "name": "ui" }), Some(&design), None)
1492            .unwrap();
1493        let general = children
1494            .create(serde_json::json!({ "name": "general" }), None, None)
1495            .unwrap();
1496        let record = RecordId::workspace_default().child(ui.as_str()).unwrap();
1497        assert_eq!(
1498            store.read(&record, |doc| Reader::<Topic>::read(doc)
1499                .unwrap()
1500                .state()
1501                .name
1502                .clone()),
1503            Some("ui".to_owned())
1504        );
1505        assert_eq!(
1506            order(&children),
1507            [
1508                ("design".to_owned(), None),
1509                ("ui".to_owned(), Some("design".to_owned())),
1510                ("general".to_owned(), None),
1511            ]
1512        );
1513        // Moved out of its group and to the front: the same record, the
1514        // same address, only its reference moved.
1515        children.move_to(&ui, None, Some(&design)).unwrap();
1516        assert_eq!(
1517            order(&children),
1518            [
1519                ("ui".to_owned(), None),
1520                ("design".to_owned(), None),
1521                ("general".to_owned(), None),
1522            ]
1523        );
1524        assert_eq!(children.child(&ui).unwrap().name, "ui");
1525        assert!(store.read(&record, |_| ()).is_some());
1526        children.move_to(&general, Some(&design), None).unwrap();
1527        assert_eq!(
1528            order(&children)[2],
1529            ("general".to_owned(), Some("design".to_owned()))
1530        );
1531        // Nothing is grouped under itself, nor under what is not there.
1532        assert!(children.move_to(&design, Some(&design), None).is_err());
1533        assert!(
1534            children
1535                .move_to(&design, Some(&Id::from_raw("absent")), None)
1536                .is_err()
1537        );
1538    }
1539
1540    #[test]
1541    fn delete_puts_a_child_in_the_trash_whole_and_restore_brings_it_back() {
1542        let (store, children) = workspace();
1543        let design = children
1544            .create(
1545                serde_json::json!({ "name": "design", "note": "kept" }),
1546                None,
1547                None,
1548            )
1549            .unwrap();
1550        let ui = children
1551            .create(serde_json::json!({ "name": "ui" }), Some(&design), None)
1552            .unwrap();
1553        children.delete(&design).unwrap();
1554        assert_eq!(
1555            order(&children),
1556            [("ui".to_owned(), Some("design".to_owned()))]
1557        );
1558        let record = RecordId::workspace_default()
1559            .child(design.as_str())
1560            .unwrap();
1561        assert_eq!(
1562            store.read(&record, |doc| Reader::<Topic>::read(doc)
1563                .unwrap()
1564                .state()
1565                .note
1566                .clone()),
1567            Some("kept".to_owned())
1568        );
1569        assert_eq!(children.child(&design).unwrap().note, "kept");
1570        children.delete(&ui).unwrap();
1571        children.restore(&ui).unwrap();
1572        assert_eq!(order(&children), [("ui".to_owned(), None)]);
1573        children.restore(&design).unwrap();
1574        assert_eq!(
1575            order(&children),
1576            [("design".to_owned(), None), ("ui".to_owned(), None)]
1577        );
1578        // A child is made only with fields its kind has.
1579        assert!(
1580            children
1581                .create(serde_json::json!({ "colour": 3 }), None, None)
1582                .is_err()
1583        );
1584        assert!(
1585            children
1586                .create(serde_json::json!({ "name": 3 }), None, None)
1587                .is_err()
1588        );
1589    }
1590
1591    #[test]
1592    fn the_door_offers_one_lifecycle_on_every_child_and_carries_each_childs_own_actions() {
1593        let (_, children) = workspace();
1594        let agent = ActionInvoker::actor("agent-1");
1595        let invoke =
1596            |resource: &tilo_actions::ResourceSpec, action: &str, args: serde_json::Value| {
1597                let spec = resource
1598                    .actions
1599                    .iter()
1600                    .find(|spec| spec.id == action)
1601                    .unwrap_or_else(|| panic!("`{action}` on {}", resource.id.as_str()));
1602                let encoded = spec.build_json(args.as_object().unwrap()).unwrap();
1603                children.invoke_published(&encoded, &agent)
1604            };
1605        let made = invoke(
1606            &children.published(),
1607            lifecycle::CREATE,
1608            serde_json::json!({ "fields": { "name": "design" } }),
1609        )
1610        .unwrap();
1611        let address = made.resources[0].clone();
1612        assert!(address.starts_with("test.topic/"));
1613        let published = children.published();
1614        let child = published
1615            .children
1616            .iter()
1617            .find(|child| child.id.as_str() == address)
1618            .unwrap();
1619        for verb in [lifecycle::DELETE, lifecycle::MOVE, "jot"] {
1620            assert!(
1621                child.actions.iter().any(|spec| spec.id == verb),
1622                "{verb} on the child"
1623            );
1624        }
1625        invoke(child, "jot", serde_json::json!({ "text": "agenda" })).unwrap();
1626        let id = Id::from_raw(address.rsplit('/').next().unwrap());
1627        assert_eq!(children.child(&id).unwrap().note, "agenda");
1628        // In the trash, it offers `restore` only.
1629        invoke(child, lifecycle::DELETE, serde_json::json!({})).unwrap();
1630        let trashed = children.published();
1631        let child = trashed
1632            .children
1633            .iter()
1634            .find(|child| child.id.as_str() == address)
1635            .unwrap();
1636        assert_eq!(
1637            child
1638                .actions
1639                .iter()
1640                .map(|spec| spec.id.as_str())
1641                .collect::<Vec<_>>(),
1642            [lifecycle::RESTORE]
1643        );
1644        invoke(child, lifecycle::RESTORE, serde_json::json!({})).unwrap();
1645        assert_eq!(children.children().len(), 1);
1646    }
1647
1648    /// The saved record `id` of `store`, as another device receives it.
1649    fn copy(from: &RecordStore, to: &RecordStore, id: &RecordId, kind: &str) {
1650        let saved = from.read(id, |doc| doc.clone().save()).unwrap();
1651        to.create_record(&RecordCheckout::new(id.clone(), RecordKind::new(kind)))
1652            .unwrap();
1653        to.with(id, |doc| {
1654            *doc = ui_core::automerge::AutoCommit::load(&saved).unwrap();
1655        })
1656        .unwrap();
1657    }
1658
1659    /// A device's store of workspace `lounge`'s records, kept as it keeps them:
1660    /// a record it has not got reads as missing, not as an error.
1661    fn device() -> RecordStore {
1662        crate::record::RecordOwners::with_store(Rc::new(crate::feedback::MemoryStore::new()))
1663            .open("so.tilo.test", "lounge")
1664            .unwrap()
1665    }
1666
1667    /// The children of the workspace `store` holds, as `author` sees them.
1668    fn children_of(store: &RecordStore) -> Children<Topic> {
1669        let read = store.clone();
1670        Children::<Topic>::detached_in(store.clone(), "so.tilo.test", "ana", move || {
1671            read.read(&RecordId::workspace_default(), |doc| {
1672                Reader::<Workspace>::read(doc).unwrap().state().clone()
1673            })
1674            .unwrap()
1675        })
1676        .unwrap()
1677    }
1678
1679    #[test]
1680    fn a_child_whose_record_is_on_its_way_reads_pending_until_it_arrives() {
1681        let here = device();
1682        let parent = RecordCheckout::new(
1683            RecordId::workspace_default(),
1684            RecordKind::new(Workspace::KIND),
1685        );
1686        here.create_record(&parent).unwrap();
1687        here.with(&parent.id, |doc| {
1688            Workspace::initialize(doc).unwrap();
1689            doc.commit();
1690        })
1691        .unwrap();
1692        let design = children_of(&here)
1693            .create(serde_json::json!({ "name": "design" }), None, None)
1694            .unwrap();
1695        // Another device has the workspace's listing, not yet the child's record.
1696        let there = device();
1697        copy(
1698            &here,
1699            &there,
1700            &RecordId::workspace_default(),
1701            Workspace::KIND,
1702        );
1703        let theirs = children_of(&there);
1704        let listed = theirs.children();
1705        let [(id, _, arriving)] = listed.as_slice() else {
1706            panic!("the one listed child: {}", listed.len());
1707        };
1708        assert_eq!(*id, design);
1709        assert!(
1710            arriving.get().is_pending(),
1711            "listed and not here is on its way, not a new child: {:?}",
1712            arriving.with(|arrival| arrival.clone().map(|_| ()))
1713        );
1714        assert!(theirs.child(&design).is_none());
1715
1716        copy(
1717            &here,
1718            &there,
1719            &RecordId::workspace_default()
1720                .child(design.as_str())
1721                .unwrap(),
1722            Topic::KIND,
1723        );
1724        let _ = theirs.revision();
1725        assert_eq!(
1726            arriving.with(|arrival| arrival.ready().map(|topic| topic.name.clone())),
1727            Some("design".to_owned()),
1728            "the same read answers once the record arrived"
1729        );
1730    }
1731}