1use 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
50pub(crate) const CHILD_ACTION_TYPE: &str = "tilo.app.child-action";
53pub(crate) const LIFECYCLE_TYPE: &str = "tilo.app.child-lifecycle";
55
56pub type Listed<C> = (Id, ChildRef, Arriving<Rc<<C as Resource>::State>>);
59
60enum Unheld {
62 Missing,
64 Unreadable(String),
66}
67
68impl From<String> for Unheld {
69 fn from(reason: String) -> Self {
70 Self::Unreadable(reason)
71 }
72}
73
74pub trait ChildRecord: Resource<State: tilo_resource::Model + Default> {
77 type Parent: Resource;
79
80 #[must_use]
82 fn parent_record() -> RecordId {
83 RecordId::workspace_default()
84 }
85
86 fn container(parent: &<Self::Parent as Resource>::State) -> &Container;
88
89 fn container_path() -> Vec<Segment>;
91
92 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
106#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
107pub(crate) enum Lifecycle {
108 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 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#[derive(Serialize, Deserialize)]
195struct ChildAction {
196 child: Id,
197 action: EncodedAction,
198}
199
200fn 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
209fn 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
220fn 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
232pub(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 arrivals: RefCell<BTreeMap<Id, Arriving<Rc<C::State>>>>,
243 unreadable: RefCell<BTreeMap<Id, String>>,
245 empty: Rc<C::State>,
248 revision: Cell<u64>,
249 published: RefCell<Option<((u64, u64), tilo_actions::ResourceSpec)>>,
250 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn route(&self, spec: ActionSpec) -> ActionSpec {
914 super::record::route_operation(spec, CHAIN_ACTION_TYPE, &self.id)
915 }
916}
917
918#[must_use]
921pub fn child_resource_id<C: Resource>(id: &Id) -> String {
922 format!("{}/{id}", C::KIND)
923}
924
925fn 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 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
958fn 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
977fn 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 Some(Id::from_raw(text.rsplit('/').next().unwrap_or(text)))
1022 }
1023 _ => None,
1024 }
1025}
1026
1027fn 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
1063fn 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
1082fn 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 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
1189pub struct Children<C: ChildRecord> {
1192 state: Rc<ChildrenState<C>>,
1193 me: String,
1194 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 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 #[must_use]
1228 pub fn id(&self) -> &str {
1229 self.state.id()
1230 }
1231
1232 #[must_use]
1236 pub fn children(&self) -> Vec<Listed<C>> {
1237 self.state.children()
1238 }
1239
1240 #[must_use]
1242 pub fn child(&self, id: &Id) -> Option<Rc<C::State>> {
1243 self.state.child(id)
1244 }
1245
1246 #[must_use]
1248 pub fn revision(&self) -> u64 {
1249 self.state.revision()
1250 }
1251
1252 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 pub fn delete(&self, id: &Id) -> Result<(), ActionError> {
1276 self.step(&Lifecycle::Delete { child: id.clone() })
1277 .map(drop)
1278 }
1279
1280 pub fn restore(&self, id: &Id) -> Result<(), ActionError> {
1285 self.step(&Lifecycle::Restore { child: id.clone() })
1286 .map(drop)
1287 }
1288
1289 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 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 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 #[must_use]
1341 pub fn published(&self) -> tilo_actions::ResourceSpec {
1342 self.state.publish()
1343 }
1344
1345 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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}