Skip to main content

tilo_app/framework/
attached.rs

1//! An app's view of one record, attached and never drawn: how a host
2//! without a screen — an agent host, a relay, a workspace service — performs
3//! actions.
4//!
5//! Nothing is drawn if no one is looking, but no action is performed out of
6//! sight. An [`AttachedView`] mounts the app's real component over the
7//! record exactly as a window does, lays it out and never paints it; every
8//! operation goes through the view's bound dispatcher, the door a person's
9//! click takes, admitted for the participant as invoker and checked against
10//! its permissions. Where the action landed is where the participant's
11//! presence shows ([`AttachedView::landed`]), and the person's devices draw
12//! the same record from synced state when someone looks.
13//!
14//! Sync is the caller's: [`AttachedView::take_changes`] returns this
15//! participant's own changes since the last call (the whole document the
16//! first time, for a record it created) and [`AttachedView::merge`] takes a
17//! peer's, through the record store's replication frontier as a browser
18//! page's mounts do. The parts of a chain the app keeps beside the record
19//! ([`Chain`](super::Chain)) sync the same way on routes of their own
20//! ([`AttachedView::take_record_changes`], [`AttachedView::merge_record`]),
21//! and the app's actions reach them through the chain the view publishes.
22
23use std::cell::RefCell;
24use std::rc::Rc;
25
26use tilo_actions::door::{ActionAddress, ActionDescriptor, DoorError};
27use tilo_actions::{ActionEnvelope, ActionOutput, ActionTransport};
28use ui_core::application::{
29    ApplicationDefinition, ComponentRunnerInputs, HostContext, HostContextInputs, LaunchTarget,
30    RestoreInput,
31};
32use ui_core::application_view::InstanceViewStore;
33use ui_core::collaboration::DocumentMutation;
34use ui_core::headless::FrameHarness;
35use ui_core::permissions::{PermissionContext, SharedPermissions, UserId};
36use ui_core::portable_storage::{PortableStorage, StorageKey};
37use ui_core::record::{RecordCheckout, RecordOwners, RecordStore};
38use ui_core::state::DocumentFrontier;
39
40/// The component instance a view mounts.
41const INSTANCE: u64 = 1;
42
43/// Where the view lays out. Never drawn; it only gives the view a size, as
44/// a window would.
45const VIEWPORT: (u32, u32) = (1280, 720);
46
47/// Device name the view's storage is kept under.
48const DEVICE: &str = "attached-view";
49
50/// The actions the view applied, as its transport replicates them.
51#[derive(Clone, Default)]
52struct Landed(Rc<RefCell<Vec<ActionEnvelope>>>);
53
54impl ActionTransport for Landed {
55    fn replicate(&mut self, env: &ActionEnvelope) {
56        self.0.borrow_mut().push(env.clone());
57    }
58}
59
60/// A record the view holds beside its own, as its sync stands.
61#[derive(Default)]
62struct Beside {
63    /// Everything taken or merged so far: what lies past it is this
64    /// participant's own.
65    frontier: DocumentFrontier,
66    /// This participant's changes set aside, untaken, while a peer's merged.
67    unsent: Vec<Vec<u8>>,
68}
69
70/// One record held in its app's view, attached and never drawn. See the
71/// module docs.
72pub struct AttachedView {
73    definition: &'static ApplicationDefinition,
74    checkout: RecordCheckout,
75    /// The app's view of the record: mounted, laid out, never drawn.
76    frame: Box<FrameHarness>,
77    /// The record store the view reads and writes.
78    records: RecordStore,
79    permissions: SharedPermissions,
80    /// Who acts through the view: the invoker its operations are admitted for.
81    me: String,
82    /// The invoker as admitted: its kind and the person it acts for.
83    invoker: tilo_actions::ActionInvoker,
84    /// The task the invoker is carrying out, recorded with what it does.
85    context: Option<tilo_actions::ActionContext>,
86    landed: Landed,
87    /// Everything taken or merged so far: what lies past it is this
88    /// participant's own.
89    frontier: DocumentFrontier,
90    /// The whole document is still to be sent: the participant created it.
91    fresh: bool,
92    /// This participant's changes set aside, untaken, while a peer's merged.
93    unsent: Vec<Vec<u8>>,
94    /// Each record the view holds beside its own (the parts of a chain), by
95    /// id: what was taken or merged of it so far.
96    beside: std::collections::HashMap<ui_core::record::RecordId, Beside>,
97    /// Where the view keeps its records, and the workspace: what a record
98    /// beside its own that a peer started is kept in before it opens.
99    cache: PortableStorage,
100    workspace: String,
101}
102
103/// An [`AttachedView`] about to open ([`AttachedView::opening`]): whom it
104/// is for, what they may do, and what the host lends the view.
105pub struct OpenView<'a> {
106    definition: &'static ApplicationDefinition,
107    workspace: &'a str,
108    checkout: RecordCheckout,
109    document: Vec<u8>,
110    invoker: tilo_actions::ActionInvoker,
111    permissions: PermissionContext,
112    created: bool,
113    content: Option<tilo_content::ContentStore>,
114}
115
116impl OpenView<'_> {
117    /// Open it for `invoker` (by default the record's owner).
118    #[must_use]
119    pub fn for_invoker(mut self, invoker: &tilo_actions::ActionInvoker) -> Self {
120        self.invoker = invoker.clone();
121        self
122    }
123
124    /// What the invoker may do through it (by default anything).
125    #[must_use]
126    pub fn with_permissions(mut self, permissions: PermissionContext) -> Self {
127        self.permissions = permissions;
128        self
129    }
130
131    /// Whether the invoker just created the record, so the first
132    /// [`AttachedView::take_changes`] sends it whole.
133    #[must_use]
134    pub const fn created(mut self, created: bool) -> Self {
135        self.created = created;
136        self
137    }
138
139    /// Lend the view the workspace's content store, as a host lends a window
140    /// its: the bytes a content hash names (an image another app made).
141    #[must_use]
142    pub fn lending(mut self, store: tilo_content::ContentStore) -> Self {
143        self.content = Some(store);
144        self
145    }
146
147    /// Mount the view.
148    ///
149    /// # Errors
150    /// As [`AttachedView::open`].
151    pub fn open(self) -> Result<AttachedView, String> {
152        AttachedView::open_view(self)
153    }
154}
155
156impl AttachedView {
157    /// Mount `definition`'s view over the record `document` holds in
158    /// `workspace`, for `me` with `permissions`. `created` when `me` just created
159    /// the record, so the first [`Self::take_changes`] sends it whole.
160    pub fn open(
161        definition: &'static ApplicationDefinition,
162        workspace: &str,
163        checkout: RecordCheckout,
164        document: Vec<u8>,
165        me: &str,
166        permissions: PermissionContext,
167        created: bool,
168    ) -> Result<Self, String> {
169        Self::open_for(
170            definition,
171            workspace,
172            checkout,
173            document,
174            &tilo_actions::ActionInvoker::actor(me),
175            permissions,
176            created,
177        )
178    }
179
180    /// [`Self::open`] for `invoker` — an agent, say, which may act for a
181    /// person — rather than a person by their id alone. Its changes are
182    /// written under the record writer's actor for its handle on this host.
183    ///
184    /// # Errors
185    /// As [`Self::open`].
186    pub fn open_for(
187        definition: &'static ApplicationDefinition,
188        workspace: &str,
189        checkout: RecordCheckout,
190        document: Vec<u8>,
191        invoker: &tilo_actions::ActionInvoker,
192        permissions: PermissionContext,
193        created: bool,
194    ) -> Result<Self, String> {
195        Self::opening(definition, workspace, checkout, document)
196            .for_invoker(invoker)
197            .with_permissions(permissions)
198            .created(created)
199            .open()
200    }
201
202    /// The view of `definition` over the record `document` holds in `workspace`,
203    /// to open once said for whom, with what and what the host lends it
204    /// ([`OpenView`]).
205    #[must_use]
206    pub fn opening<'a>(
207        definition: &'static ApplicationDefinition,
208        workspace: &'a str,
209        checkout: RecordCheckout,
210        document: Vec<u8>,
211    ) -> OpenView<'a> {
212        OpenView {
213            definition,
214            workspace,
215            checkout,
216            document,
217            invoker: tilo_actions::ActionInvoker::actor("owner"),
218            permissions: PermissionContext::permissive(),
219            created: false,
220            content: None,
221        }
222    }
223
224    fn open_view(view: OpenView<'_>) -> Result<Self, String> {
225        let OpenView {
226            definition,
227            workspace,
228            checkout,
229            document,
230            invoker,
231            permissions,
232            created,
233            content,
234        } = view;
235        let invoker = &invoker;
236        let me = invoker.actor.as_str();
237        let joined_at = (!created).then(|| heads_of(&document)).transpose()?;
238        let cache = PortableStorage::new("attached-views", DEVICE)?;
239        let key = if checkout.id.is_package_primary() {
240            StorageKey::Primary {
241                package: definition.app_id().into(),
242                workspace: workspace.to_owned(),
243            }
244        } else {
245            StorageKey::Record {
246                package: definition.app_id().into(),
247                workspace: workspace.to_owned(),
248                record: checkout.id.as_str().into(),
249            }
250        };
251        // The record is written by the page that opens it, which mints its
252        // own writer then; the handle writes what it does through actions.
253        cache.hydrate(key, document, None)?;
254        let cache_kept = cache.clone();
255        let records = RecordOwners::portable(cache.clone())
256            .open(definition.app_id(), workspace)
257            .map_err(|error| error.to_string())?;
258        let views = Rc::new(
259            InstanceViewStore::portable(cache, workspace).map_err(|error| error.to_string())?,
260        );
261        let permissions = SharedPermissions::new(permissions);
262        let target = LaunchTarget::Record {
263            checkout: checkout.clone(),
264            reveal: None,
265        };
266        let context = HostContext::prepare(
267            definition,
268            HostContextInputs {
269                records: records.clone(),
270                views,
271                device_id: DEVICE.into(),
272                instance_id: INSTANCE,
273                permissions: permissions.clone(),
274                author: UserId(me.to_owned()),
275                action_context: tilo_actions::ActionContext::default()
276                    .with_invoker(invoker.clone()),
277            },
278            &target,
279        )
280        .map_err(|error| error.to_string())?;
281        let mut context = context;
282        if let Some(store) = content {
283            context.provide_content(store);
284        }
285        let component = definition
286            .instantiate(context, target, RestoreInput::None)
287            .map_err(|error| error.to_string())?;
288        let mut frame = FrameHarness::from_component(
289            component,
290            VIEWPORT,
291            ComponentRunnerInputs {
292                theme: ui_core::Theme::default(),
293                text_renderer: ui_core::TextRenderer::new(),
294            },
295        )
296        .map_err(|error| error.to_string())?;
297        let landed = Landed::default();
298        frame.set_action_transport(Box::new(landed.clone()))?;
299        if let Some(joined_at) = &joined_at {
300            refuse_written_on_open(definition, &records, &checkout, joined_at)?;
301        }
302        // A record joined starts where it was handed over: the only write
303        // its view may make on open is a storage migration, which is sent
304        // with the participant's first changes, as a window's is, so the
305        // workspace and its other holders take it rather than diverge.
306        let mut frontier = DocumentFrontier::default();
307        let start = joined_at
308            .or_else(|| records.with(&checkout.id, ui_core::automerge::AutoCommit::get_heads));
309        if let Some(heads) = start {
310            frontier.advance(heads);
311        }
312        Ok(Self {
313            definition,
314            checkout,
315            frame: Box::new(frame),
316            records,
317            permissions,
318            me: me.to_owned(),
319            invoker: invoker.clone(),
320            context: None,
321            landed,
322            frontier,
323            fresh: created,
324            unsent: Vec::new(),
325            beside: std::collections::HashMap::new(),
326            cache: cache_kept,
327            workspace: workspace.to_owned(),
328        })
329    }
330
331    /// The app whose view this is.
332    #[must_use]
333    pub const fn definition(&self) -> &'static ApplicationDefinition {
334        self.definition
335    }
336
337    #[must_use]
338    pub const fn checkout(&self) -> &RecordCheckout {
339        &self.checkout
340    }
341
342    /// Who acts through the view.
343    #[must_use]
344    pub fn me(&self) -> &str {
345        &self.me
346    }
347
348    /// The participant's permissions changed: a role granted or taken away.
349    pub fn set_permissions(&mut self, permissions: PermissionContext) {
350        self.permissions.set(permissions);
351        self.frame.relayout();
352    }
353
354    /// The tree the view publishes, as the participant may reach it.
355    #[must_use]
356    pub fn resources(&self) -> Vec<tilo_actions::ResourceSpec> {
357        let permissions = self.permissions.borrow();
358        self.frame
359            .resources()
360            .into_iter()
361            .filter_map(|resource| {
362                permissions.permitted_resource(self.definition.app_id(), resource)
363            })
364            .collect()
365    }
366
367    /// Every document operation the participant may perform through the
368    /// view now, with its typed parameters.
369    #[must_use]
370    pub fn operations(&self) -> Vec<ActionDescriptor> {
371        tilo_actions::door::survey(&self.resources())
372            .into_iter()
373            .filter(|descriptor| descriptor.kind == tilo_actions::OperationKind::Document)
374            .collect()
375    }
376
377    /// Perform the operation `address` names through the view, for the
378    /// participant: permission, confirmation and strict argument binding
379    /// exactly as for a person's click (`confirmed` when the person it acts
380    /// for confirmed it). The change it made leaves with
381    /// [`Self::take_changes`].
382    pub fn invoke(
383        &mut self,
384        address: &ActionAddress,
385        arguments: &serde_json::Map<String, serde_json::Value>,
386        confirmed: bool,
387    ) -> Result<ActionOutput, DoorError> {
388        self.invoke_appearing(address, arguments, confirmed, None)
389    }
390
391    /// [`Self::invoke`] showing `appearance`, which the door already admitted
392    /// for this one invocation. `None` clears any appearance the view's
393    /// context carried: only a door sets one.
394    ///
395    /// # Errors
396    /// As [`Self::invoke`].
397    pub fn invoke_appearing(
398        &mut self,
399        address: &ActionAddress,
400        arguments: &serde_json::Map<String, serde_json::Value>,
401        confirmed: bool,
402        appearance: Option<tilo_actions::Appearance>,
403    ) -> Result<ActionOutput, DoorError> {
404        let invoker = self.invoker_appearing(appearance);
405        self.invoke_as(invoker, address, arguments, confirmed)
406    }
407
408    /// Whom [`Self::invoke_appearing`] admits an operation for: the
409    /// participant, or the person the task says it acts for now, showing
410    /// `appearance`.
411    #[must_use]
412    pub fn invoker_appearing(
413        &self,
414        appearance: Option<tilo_actions::Appearance>,
415    ) -> tilo_actions::ActionInvoker {
416        self.context
417            .as_ref()
418            .and_then(|context| context.invoker.clone())
419            .filter(|invoker| invoker.actor == self.invoker.actor)
420            .unwrap_or_else(|| self.invoker.clone())
421            .with_appearance(appearance)
422    }
423
424    /// [`Self::invoke`] for `invoker`, whom the host admitted for this one
425    /// invocation (an agent's, delivered to the app that owns the record):
426    /// the operation and every edit it makes are theirs. Any operation the
427    /// view offers — a document action, or an edit at the invoker's own
428    /// caret.
429    ///
430    /// # Errors
431    /// As [`Self::invoke`].
432    pub fn invoke_as(
433        &mut self,
434        invoker: tilo_actions::ActionInvoker,
435        address: &ActionAddress,
436        arguments: &serde_json::Map<String, serde_json::Value>,
437        confirmed: bool,
438    ) -> Result<ActionOutput, DoorError> {
439        let (_, started) = self.start_as(invoker, address, arguments, confirmed)?;
440        // It answers once what it caused in the app has landed: a reply the
441        // app asked a worker for in turn is applied as part of it, not of
442        // whatever is invoked next. A job it started answers by itself.
443        self.frame.settle_owed();
444        Ok(started.completed().unwrap_or_default())
445    }
446
447    /// [`Self::invoke_as`], waiting up to `within` for an operation that
448    /// runs as a job (rendering a picture, exporting a file's worth) to end
449    /// and answering with what it ended as, where [`Self::invoke_as`]
450    /// answers a job at once with nothing: for a host that hands an
451    /// effect's result back to whoever asked, an agent planning its next
452    /// step on it. A job still running when `within` is up is cancelled.
453    ///
454    /// # Errors
455    /// As [`Self::invoke_as`]; the job's failure or cancellation, or that it
456    /// did not end in time.
457    pub fn invoke_as_awaiting(
458        &mut self,
459        invoker: tilo_actions::ActionInvoker,
460        address: &ActionAddress,
461        arguments: &serde_json::Map<String, serde_json::Value>,
462        confirmed: bool,
463        within: ui_core::time::Duration,
464    ) -> Result<ActionOutput, DoorError> {
465        let (invocation, started) = self.start_as(invoker, address, arguments, confirmed)?;
466        if let Some(output) = started.completed() {
467            self.frame.settle_owed();
468            return Ok(output);
469        }
470        let ended = |frame: &FrameHarness| {
471            frame
472                .job_updates(invocation)
473                .iter()
474                .find(|update| update.is_terminal())
475                .cloned()
476        };
477        if self
478            .frame
479            .wait_until(within, |frame| ended(frame).is_some())
480        {
481            // What the job's end caused is applied before it answers too.
482            self.frame.settle_owed();
483        }
484        match ended(&self.frame) {
485            Some(tilo_actions::InvocationUpdate::Succeeded { output, .. }) => Ok(output),
486            Some(tilo_actions::InvocationUpdate::Failed { error, .. }) => {
487                Err(DoorError::Failed(error.message))
488            }
489            Some(_) => Err(DoorError::Failed(format!(
490                "{} was cancelled",
491                address.action
492            ))),
493            None => {
494                let _ = self.frame.cancel_job(invocation);
495                Err(DoorError::Failed(format!(
496                    "{} did not finish within {} seconds",
497                    address.action,
498                    within.as_secs()
499                )))
500            }
501        }
502    }
503
504    /// Start the operation `address` names for `invoker`, as
505    /// [`Self::invoke_as`] does: its invocation, and whether it finished.
506    fn start_as(
507        &mut self,
508        invoker: tilo_actions::ActionInvoker,
509        address: &ActionAddress,
510        arguments: &serde_json::Map<String, serde_json::Value>,
511        confirmed: bool,
512    ) -> Result<
513        (
514            tilo_actions::InvocationId,
515            ui_core::host_operation::ResourceOperationStart,
516        ),
517        DoorError,
518    > {
519        if !tilo_actions::door::survey(&self.resources())
520            .iter()
521            .any(|entry| &entry.address == address)
522        {
523            return Err(DoorError::Unknown(address.clone()));
524        }
525        self.landed.0.borrow_mut().clear();
526        // History names the app the view is of, as its window does, within
527        // whatever task the participant carries out.
528        let ambient = self.context.clone().unwrap_or_default().enriched_by(
529            &tilo_actions::ActionContext::default()
530                .with_app(self.definition.app_id(), self.definition.title()),
531        );
532        self.frame.start_bound_job_within(
533            Some(invoker),
534            Some(&ambient),
535            &ui_core::bound_dispatch::BoundActionAddress {
536                instance_id: INSTANCE,
537                address: address.clone(),
538            },
539            arguments,
540            confirmed,
541        )
542    }
543
544    /// Perform `invoke`, an action of the record's resource `R` built from
545    /// its typed reference (`Note::EDIT.with(..)`, `Invoke::edit(..)`),
546    /// through the view as [`Self::invoke`] does.
547    ///
548    /// # Errors
549    /// As [`Self::invoke`], and when `invoke` does not bind or the view
550    /// publishes no `R`.
551    pub fn invoke_typed<R: tilo_resource::Resource>(
552        &mut self,
553        invoke: &tilo_resource::Invoke<R>,
554        confirmed: bool,
555    ) -> Result<ActionOutput, DoorError> {
556        if let Some(refusal) = invoke.refusal() {
557            return Err(DoorError::InvalidArguments(refusal.to_owned()));
558        }
559        // The record this view draws is the one its actions are published
560        // under: one of another app's, kept here, is reached through the
561        // provider that keeps it.
562        let app = self.definition.app_id();
563        let resource = self
564            .resources()
565            .into_iter()
566            .find(|resource| resource.kind == R::KIND && resource.app().is_none_or(|of| of == app))
567            .ok_or_else(|| DoorError::Unknown(ActionAddress::new(R::KIND, invoke.id)))?;
568        self.invoke(
569            &ActionAddress::new(resource.id.as_str(), invoke.id),
570            &invoke.arguments,
571            confirmed,
572        )
573    }
574
575    /// [`Self::invoke`] acting at `place` instead of at the participant's
576    /// caret: `place` is given as the operation's place parameter
577    /// (`#[param(place)]`), and the participant lands there.
578    ///
579    /// # Errors
580    /// As [`Self::invoke`]; refused as invalid arguments when the operation
581    /// takes no place.
582    pub fn invoke_at(
583        &mut self,
584        place: &tilo_resource::Place,
585        address: &ActionAddress,
586        arguments: &serde_json::Map<String, serde_json::Value>,
587        confirmed: bool,
588    ) -> Result<ActionOutput, DoorError> {
589        let resources = self.resources();
590        let param = tilo_actions::door::survey(&resources)
591            .into_iter()
592            .find(|entry| &entry.address == address)
593            .ok_or_else(|| DoorError::Unknown(address.clone()))?
594            .params
595            .iter()
596            .find(|param| param.is_place())
597            .map(|param| param.name.to_string())
598            .ok_or_else(|| {
599                DoorError::InvalidArguments(format!(
600                    "`{}` takes no place to act at",
601                    address.action
602                ))
603            })?;
604        let mut arguments = arguments.clone();
605        arguments.insert(
606            param,
607            serde_json::to_value(place)
608                .map_err(|error| DoorError::InvalidArguments(error.to_string()))?,
609        );
610        self.invoke(address, &arguments, confirmed)
611    }
612
613    /// The task the participant carries out from now on (`None`: none),
614    /// recorded with each thing it does.
615    pub fn set_context(&mut self, context: Option<tilo_actions::ActionContext>) {
616        self.context = context;
617    }
618
619    /// The task the participant carries out, when it has one.
620    #[must_use]
621    pub const fn context(&self) -> Option<&tilo_actions::ActionContext> {
622        self.context.as_ref()
623    }
624
625    /// Record one instruction that changed several records as one run in
626    /// this record's history: a marker, no content. Leaves with
627    /// [`Self::take_changes`]; whether anything was recorded.
628    pub fn record_run(
629        &mut self,
630        label: &str,
631        issued_by: Option<&str>,
632        decision: Option<&str>,
633        steps: &[ui_core::RunStep],
634    ) -> bool {
635        self.records
636            .with(&self.checkout.id, |doc| {
637                let by = doc.get_actor().to_hex_string();
638                let recorded = ui_core::record_run_with_details(
639                    doc,
640                    label,
641                    Some(&by),
642                    issued_by,
643                    Some(label),
644                    decision,
645                    steps,
646                );
647                if recorded.is_some() {
648                    tilo_resource::history::commit_stamped_with_message(
649                        doc,
650                        Some(tilo_actions::FrameworkChange::Run.change_message()),
651                    );
652                }
653                recorded.is_some()
654            })
655            .unwrap_or(false)
656    }
657
658    /// The last action the view applied for the participant's latest
659    /// invocation: where its presence shows.
660    #[must_use]
661    pub fn landed(&self) -> Option<ActionEnvelope> {
662        self.landed.0.borrow().last().cloned()
663    }
664
665    /// Read the record's document.
666    pub fn read<R>(&self, read: impl FnOnce(&ui_core::automerge::AutoCommit) -> R) -> Option<R> {
667        self.records.read(&self.checkout.id, read)
668    }
669
670    /// The participant's changes since the last call, or `None`. The first
671    /// call on a record the participant created returns it whole.
672    pub fn take_changes(&mut self) -> Option<DocumentMutation> {
673        if std::mem::take(&mut self.fresh) {
674            self.unsent.clear();
675            let (bytes, heads) = self.records.replication_snapshot(&self.checkout.id).ok()?;
676            self.frontier.advance(heads);
677            return Some(DocumentMutation::Snapshot(bytes));
678        }
679        self.set_aside_own();
680        let own: Vec<u8> = std::mem::take(&mut self.unsent).concat();
681        (!own.is_empty()).then_some(DocumentMutation::Incremental(own))
682    }
683
684    /// Merge a peer's change into the view's record. Incremental changes
685    /// that build on nothing this record holds belong to another record (or
686    /// arrived ahead of what they build on) and are left alone: `Ok(false)`.
687    pub fn merge(&mut self, mutation: &DocumentMutation) -> Result<bool, String> {
688        if let DocumentMutation::Incremental(bytes) = mutation {
689            let mut incoming = ui_core::automerge::AutoCommit::new();
690            if tilo_automerge::ApplyIncoming::load_incoming(&mut incoming, bytes).is_err() {
691                return Ok(false);
692            }
693            let missing = incoming.get_missing_deps(&[]);
694            let known = self
695                .records
696                .with(&self.checkout.id, |doc| {
697                    missing
698                        .iter()
699                        .all(|hash| doc.get_change_by_hash(hash).is_some())
700                })
701                .unwrap_or(false);
702            if !known {
703                return Ok(false);
704            }
705        }
706        // What this participant did and has not taken stays its own.
707        if !self.fresh {
708            self.set_aside_own();
709        }
710        let (changed, observed) = self
711            .records
712            .merge_replica(&self.checkout, mutation.clone())?;
713        self.frontier.observe(observed);
714        self.frame.relayout();
715        Ok(changed)
716    }
717
718    /// The chains of records the app keeps beside its own
719    /// ([`Chain`](super::Chain)), as the view publishes them: each chain's
720    /// id and the kind of its parts.
721    #[must_use]
722    pub fn chains(&self) -> Vec<RecordCheckout> {
723        // Published beside the app's resource, among its children.
724        self.frame
725            .resources()
726            .iter()
727            .flat_map(|resource| std::iter::once(resource).chain(&resource.children))
728            .filter_map(|resource| {
729                let chain = super::chain_of_resource_id(resource.id.as_str())?;
730                Some(RecordCheckout::new(
731                    chain,
732                    ui_core::record::RecordKind::new(resource.kind.clone()),
733                ))
734            })
735            .collect()
736    }
737
738    /// `record` as the view holds it beside its own: a part of one of the
739    /// app's chains ([`Self::chains`]), of that chain's kind. `None` for
740    /// any other record.
741    #[must_use]
742    pub fn beside(&self, record: &ui_core::record::RecordId) -> Option<RecordCheckout> {
743        let (chain, _) = record.chain_part()?;
744        self.chains()
745            .into_iter()
746            .find(|held| held.id == chain)
747            .map(|held| RecordCheckout::new(record.clone(), held.kind))
748    }
749
750    /// The records the view holds beside its own: the parts of the app's
751    /// chains ([`Self::chains`]) open here, as they sync on routes of their
752    /// own.
753    #[must_use]
754    pub fn records_beside(&self) -> Vec<RecordCheckout> {
755        let chains = self.chains();
756        self.records
757            .open_records()
758            .into_iter()
759            .filter(|checkout| {
760                checkout
761                    .id
762                    .chain_part()
763                    .is_some_and(|(chain, _)| chains.iter().any(|held| held.id == chain))
764            })
765            .collect()
766    }
767
768    pub fn read_record<R>(
769        &self,
770        record: &ui_core::record::RecordId,
771        read: impl FnOnce(&ui_core::automerge::AutoCommit) -> R,
772    ) -> Option<R> {
773        self.beside(record)?;
774        self.records.read(record, read)
775    }
776
777    /// The participant's changes to `record`, one the view holds beside
778    /// its own ([`Self::records_beside`]), since the last call: the whole
779    /// record the first time, for one the participant started.
780    pub fn take_record_changes(
781        &mut self,
782        record: &ui_core::record::RecordId,
783    ) -> Option<DocumentMutation> {
784        if self.beside.contains_key(record) {
785            self.set_aside_beside(record);
786            let held = self.beside.get_mut(record)?;
787            let own: Vec<u8> = std::mem::take(&mut held.unsent).concat();
788            (!own.is_empty()).then_some(DocumentMutation::Incremental(own))
789        } else {
790            let (bytes, heads) = self.records.replication_snapshot(record).ok()?;
791            let mut held = Beside::default();
792            held.frontier.advance(heads);
793            self.beside.insert(record.clone(), held);
794            Some(DocumentMutation::Snapshot(bytes))
795        }
796    }
797
798    /// Merge a peer's change into `record`, a part of one of the app's
799    /// chains ([`Self::beside`]), opening it first when the view holds it
800    /// not yet (a part another device started). Changes that build on
801    /// nothing the part holds are left alone: `Ok(false)`. Whether it
802    /// changed.
803    pub fn merge_record(
804        &mut self,
805        record: &ui_core::record::RecordId,
806        mutation: &DocumentMutation,
807    ) -> Result<bool, String> {
808        let checkout = self.beside(record).ok_or_else(|| {
809            format!(
810                "{} is not a part of a chain {} keeps",
811                record.as_str(),
812                self.definition.app_id()
813            )
814        })?;
815        if !self.records.is_ready(record) && self.records.try_open_record(&checkout).is_err() {
816            // Kept as the peer sent it, then opened as the view's own are.
817            let DocumentMutation::Snapshot(bytes) = mutation else {
818                return Ok(false);
819            };
820            self.cache.hydrate(
821                StorageKey::Record {
822                    package: self.definition.app_id().into(),
823                    workspace: self.workspace.clone(),
824                    record: record.as_str().into(),
825                },
826                bytes.clone(),
827                None,
828            )?;
829            self.records
830                .try_open_record(&checkout)
831                .map_err(|error| error.to_string())?;
832        }
833        if let DocumentMutation::Incremental(bytes) = mutation
834            && !self.builds_on(record, bytes)
835        {
836            return Ok(false);
837        }
838        // What this participant did and has not taken stays its own.
839        self.set_aside_beside(record);
840        let (changed, observed) = self.records.merge_replica(&checkout, mutation.clone())?;
841        self.beside
842            .entry(record.clone())
843            .or_default()
844            .frontier
845            .observe(observed);
846        self.frame.relayout();
847        Ok(changed)
848    }
849
850    /// Whether the changes `bytes` build only on what `record` holds.
851    fn builds_on(&self, record: &ui_core::record::RecordId, bytes: &[u8]) -> bool {
852        let mut incoming = ui_core::automerge::AutoCommit::new();
853        if tilo_automerge::ApplyIncoming::load_incoming(&mut incoming, bytes).is_err() {
854            return false;
855        }
856        let missing = incoming.get_missing_deps(&[]);
857        self.records
858            .with(record, |doc| {
859                missing
860                    .iter()
861                    .all(|hash| doc.get_change_by_hash(hash).is_some())
862            })
863            .unwrap_or(false)
864    }
865
866    /// Move this participant's changes to `record`, held beside the view's
867    /// own, past its frontier into its `unsent`.
868    fn set_aside_beside(&mut self, record: &ui_core::record::RecordId) {
869        let Some(held) = self.beside.get_mut(record) else {
870            return;
871        };
872        if let Ok((delta, heads)) = self.records.own_delta(record, &mut held.frontier) {
873            held.frontier.advance(heads);
874            if !delta.is_empty() {
875                held.unsent.push(delta);
876            }
877        }
878    }
879
880    /// Move this participant's changes past the frontier into `unsent`.
881    fn set_aside_own(&mut self) {
882        if let Ok((delta, heads)) = self
883            .records
884            .own_delta(&self.checkout.id, &mut self.frontier)
885        {
886            self.frontier.advance(heads);
887            if !delta.is_empty() {
888                self.unsent.push(delta);
889            }
890        }
891    }
892}
893
894/// The heads of the record `document` holds: what a participant joining it
895/// was given, past which anything its view writes while it opens is refused
896/// ([`refuse_written_on_open`]).
897fn heads_of(document: &[u8]) -> Result<Vec<ui_core::automerge::ChangeHash>, String> {
898    ui_core::automerge::AutoCommit::load(document)
899        .map(|mut doc| doc.get_heads())
900        .map_err(|error| error.to_string())
901}
902
903/// Refuse a view that wrote to the record `checkout` names, one its
904/// participant joined at `joined_at`, as it opened ([`written_on_open`]).
905fn refuse_written_on_open(
906    definition: &ApplicationDefinition,
907    records: &RecordStore,
908    checkout: &RecordCheckout,
909    joined_at: &[ui_core::automerge::ChangeHash],
910) -> Result<(), String> {
911    match records.with(&checkout.id, |doc| written_on_open(doc, joined_at)) {
912        Some(Some(wrote)) => Err(format!(
913            "{} wrote to a record it joined as it opened ({wrote}): only the record's \
914             creator writes its initial content, and a participant that joins adds \
915             nothing on open. What the record starts with belongs in the app's seed; \
916             what a participant adds belongs behind a control they use",
917            definition.app_id
918        )),
919        _ => Ok(()),
920    }
921}
922
923/// What the app's view wrote to `doc` past `joined_at` while it opened, named
924/// for the message that refuses it; `None` when it wrote nothing. A storage
925/// migration, or the structure and identity every member derives the same
926/// way, is the framework's and none of the app's.
927///
928/// Refused rather than kept: the participant's own changes are sent from
929/// where it joined, so the view's write would be sent as if the participant
930/// had made it, which no member did. Deferring it until
931/// the record's content arrives would only move that fork later.
932fn written_on_open(
933    doc: &mut ui_core::automerge::AutoCommit,
934    joined_at: &[ui_core::automerge::ChangeHash],
935) -> Option<String> {
936    use tilo_actions::{ActionContext, Did, FrameworkChange, HistoryOp};
937    let wrote: Vec<String> = doc
938        .get_changes(joined_at)
939        .into_iter()
940        .filter_map(|change| {
941            let message = change.message()?;
942            if message.starts_with(tilo_actions::CONTINUATION_PREFIX)
943                || FrameworkChange::from_change_message(message) == Some(FrameworkChange::Native)
944            {
945                return Some("an edit".to_owned());
946            }
947            Some(match ActionContext::from_change_message(message)?.what? {
948                Did::Verb(verb) => format!("`{}`", verb.action.action),
949                Did::Edit(edit) => format!("an edit of `{}`", edit.field),
950                Did::Group(_) => "a group of edits".to_owned(),
951                Did::History(step) if matches!(step.op, HistoryOp::Migrate { .. }) => return None,
952                Did::History(_) => "a history step".to_owned(),
953            })
954        })
955        .collect();
956    (!wrote.is_empty()).then(|| wrote.join(", "))
957}