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