aede_core/
user_relink.rs

1//! Explicit reattachment with an owner-scoped, conflict-checked undo record.
2
3use super::*;
4use crate::json::Json;
5
6/// A user's explicit decision to reattach personal data.
7#[derive(Debug, Clone)]
8pub struct Relink {
9    /// Stable identifier inside this user store, used by undo.
10    pub id: u64,
11    /// Owner whose records were moved.
12    pub owner: UserRef,
13    /// Original unresolved reference.
14    pub from: EntityRef,
15    /// Explicitly selected destination.
16    pub to: EntityRef,
17    /// Decision time in Unix seconds.
18    pub at: u64,
19    /// Undo time, when this decision has been taken back.
20    pub undone_at: Option<u64>,
21    // Local IDs may be reassigned on import. Keep the original sequence to
22    // distinguish successive decisions made in the same second on one store.
23    pub(super) origin_id: u64,
24    pub(super) before: UserData,
25}
26
27/// Counts of personal records moved by one explicit reattachment.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct RelinkSummary {
30    /// Entity annotations.
31    pub annotations: usize,
32    /// Recent listening events.
33    pub plays: usize,
34    /// All-time play counters.
35    pub counts: usize,
36    /// Relationship annotations with an affected endpoint.
37    pub relations: usize,
38    /// Static playlists containing at least one affected entry.
39    pub playlists: usize,
40    /// Client-declared listens without duration/completion evidence.
41    pub scrobbles: usize,
42}
43
44fn snapshot(data: &UserData, owner: &str, target: &EntityRef) -> UserData {
45    UserData {
46        annotations: data
47            .annotations
48            .iter()
49            .filter(|a| a.owner == owner && a.target == *target)
50            .cloned()
51            .collect(),
52        plays: data
53            .plays
54            .iter()
55            .filter(|p| p.owner == owner && p.track == *target)
56            .cloned()
57            .collect(),
58        counts: data
59            .counts
60            .iter()
61            .filter(|c| c.owner == owner && c.track == *target)
62            .cloned()
63            .collect(),
64        playlists: data
65            .playlists
66            .iter()
67            .filter(|p| p.owner == owner && p.tracks.contains(target))
68            .cloned()
69            .collect(),
70        scrobbles: data
71            .scrobbles
72            .iter()
73            .filter(|p| p.owner == owner && p.track == *target)
74            .cloned()
75            .collect(),
76        relation_annotations: data
77            .relation_annotations
78            .iter()
79            .filter(|a| {
80                a.owner == owner && (a.relation.source == *target || a.relation.target == *target)
81            })
82            .cloned()
83            .collect(),
84        ..Default::default()
85    }
86}
87
88fn summary(data: &UserData) -> RelinkSummary {
89    RelinkSummary {
90        annotations: data.annotations.len(),
91        plays: data.plays.len(),
92        counts: data.counts.len(),
93        relations: data.relation_annotations.len(),
94        playlists: data.playlists.len(),
95        scrobbles: data.scrobbles.len(),
96    }
97}
98
99fn has_records(data: &UserData) -> bool {
100    summary(data) != RelinkSummary::default()
101}
102
103fn rewrite(data: &mut UserData, owner: &str, from: &EntityRef, to: &EntityRef) {
104    for target in data
105        .annotations
106        .iter_mut()
107        .filter(|a| a.owner == owner)
108        .map(|a| &mut a.target)
109        .chain(
110            data.plays
111                .iter_mut()
112                .filter(|p| p.owner == owner)
113                .map(|p| &mut p.track),
114        )
115        .chain(
116            data.counts
117                .iter_mut()
118                .filter(|c| c.owner == owner)
119                .map(|c| &mut c.track),
120        )
121        .chain(
122            data.relation_annotations
123                .iter_mut()
124                .filter(|a| a.owner == owner)
125                .flat_map(|a| [&mut a.relation.source, &mut a.relation.target]),
126        )
127        .chain(
128            data.scrobbles
129                .iter_mut()
130                .filter(|p| p.owner == owner)
131                .map(|p| &mut p.track),
132        )
133        .chain(
134            data.playlists
135                .iter_mut()
136                .filter(|p| p.owner == owner)
137                .flat_map(|p| &mut p.tracks),
138        )
139    {
140        if target == from {
141            *target = to.clone();
142        }
143    }
144}
145
146impl UserData {
147    /// Validates an explicit reattachment without changing any data.
148    ///
149    /// The source must be waiting, the destination must exist and have the same
150    /// kind, and that owner's destination must hold no conflicting personal
151    /// record. Other owners are untouched and never cause a conflict.
152    pub fn preview_relink(
153        &self,
154        owner: &str,
155        from: &EntityRef,
156        to: &EntityRef,
157        catalog: &Catalog,
158    ) -> Result<RelinkSummary, String> {
159        if from.kind != to.kind {
160            return Err("reattachment requires two references of the same kind".into());
161        }
162        if from.resolve(catalog).is_some() {
163            return Err(
164                "the source still exists; only waiting references can be reattached".into(),
165            );
166        }
167        if to.resolve(catalog).is_none() {
168            return Err("the destination is not in the catalog".into());
169        }
170        let records = snapshot(self, owner, from);
171        if !has_records(&records) {
172            return Err("no personal record for this owner uses the source reference".into());
173        }
174        let destination_id = to.resolve(catalog);
175        let conflicting = self
176            .annotations
177            .iter()
178            .filter(|a| a.owner == owner)
179            .map(|a| &a.target)
180            .chain(
181                self.plays
182                    .iter()
183                    .filter(|p| p.owner == owner)
184                    .map(|p| &p.track),
185            )
186            .chain(
187                self.counts
188                    .iter()
189                    .filter(|c| c.owner == owner)
190                    .map(|c| &c.track),
191            )
192            .chain(
193                self.relation_annotations
194                    .iter()
195                    .filter(|a| a.owner == owner)
196                    .flat_map(|a| [&a.relation.source, &a.relation.target]),
197            )
198            .chain(
199                self.scrobbles
200                    .iter()
201                    .filter(|p| p.owner == owner)
202                    .map(|p| &p.track),
203            )
204            .chain(
205                self.playlists
206                    .iter()
207                    .filter(|p| p.owner == owner)
208                    .flat_map(|p| &p.tracks),
209            )
210            .any(|reference| {
211                reference.kind == to.kind && reference.resolve(catalog) == destination_id
212            });
213        if conflicting {
214            return Err("the destination already has personal data; reattachment would merge or overwrite it".into());
215        }
216        Ok(summary(&records))
217    }
218
219    /// Applies a validated reattachment, preserving its original records for undo.
220    pub fn relink(
221        &mut self,
222        owner: &str,
223        from: &EntityRef,
224        to: &EntityRef,
225        catalog: &Catalog,
226        now: u64,
227    ) -> Result<u64, String> {
228        self.preview_relink(owner, from, to, catalog)?;
229        let destination = to
230            .resolve(catalog)
231            .and_then(|id| EntityRef::of(catalog, to.kind, id))
232            .ok_or("the destination cannot be named stably")?;
233        if destination != *to {
234            self.preview_relink(owner, from, &destination, catalog)?;
235        }
236        let id = self
237            .relinks
238            .iter()
239            .map(|row| row.id)
240            .max()
241            .unwrap_or(0)
242            .checked_add(1)
243            .ok_or("reattachment identifiers are exhausted")?;
244        let before = snapshot(self, owner, from);
245        rewrite(self, owner, from, &destination);
246        self.relinks.push(Relink {
247            id,
248            owner: owner.into(),
249            from: from.clone(),
250            to: destination,
251            at: now,
252            undone_at: None,
253            origin_id: id,
254            before,
255        });
256        reconcile(self, catalog);
257        Ok(id)
258    }
259
260    /// Checks undo without mutation, refusing edits or newly conflicting data.
261    ///
262    /// Undo never discards new listening events or note edits. If the moved
263    /// records have changed, it refuses and leaves the store unchanged.
264    pub fn preview_undo_relink(&self, owner: &str, id: u64) -> Result<RelinkSummary, String> {
265        let event = self
266            .relinks
267            .iter()
268            .find(|row| row.id == id && row.owner == owner)
269            .ok_or("no reattachment with that ID belongs to this owner")?;
270        if event.undone_at.is_some() {
271            return Err("this reattachment has already been undone".into());
272        }
273        if has_records(&snapshot(self, owner, &event.from)) {
274            return Err(
275                "the original reference now has personal data; undo would overwrite it".into(),
276            );
277        }
278        let mut expected = event.before.clone();
279        rewrite(&mut expected, owner, &event.from, &event.to);
280        let current = snapshot(self, owner, &event.to);
281        if current.annotations != expected.annotations
282            || current.plays != expected.plays
283            || current.counts != expected.counts
284            || current.relation_annotations != expected.relation_annotations
285            || current.playlists != expected.playlists
286            || current.scrobbles != expected.scrobbles
287        {
288            return Err(
289                "the reattached records have changed; undo would discard newer personal data"
290                    .into(),
291            );
292        }
293        Ok(summary(&expected))
294    }
295
296    /// Takes back one reattachment after the same checks as its preview.
297    pub fn undo_relink(&mut self, owner: &str, id: u64, now: u64) -> Result<RelinkSummary, String> {
298        let result = self.preview_undo_relink(owner, id)?;
299        let event = self
300            .relinks
301            .iter()
302            .find(|row| row.id == id && row.owner == owner)
303            .ok_or("reattachment is missing")?;
304        let (from, to) = (event.from.clone(), event.to.clone());
305        rewrite(self, owner, &to, &from);
306        if let Some(event) = self
307            .relinks
308            .iter_mut()
309            .find(|row| row.id == id && row.owner == owner)
310        {
311            event.undone_at = Some(now);
312        }
313        Ok(result)
314    }
315}
316
317impl Relink {
318    pub(super) fn to_json(&self) -> Json {
319        let mut row = Json::obj();
320        row.set("id", self.id.into());
321        row.set("origin_id", self.origin_id.into());
322        row.set("owner", self.owner.as_str().into());
323        row.set("from", self.from.to_token().into());
324        row.set("to", self.to.to_token().into());
325        row.set("at", self.at.into());
326        if let Some(at) = self.undone_at {
327            row.set("undone_at", at.into());
328        }
329        row.set("before", super::to_json(&self.before));
330        row
331    }
332
333    pub(super) fn from_json(row: &Json) -> Option<Self> {
334        let before = row.get("before")?;
335        // Undo snapshots are flat records, never nested command histories.
336        if !before
337            .get("relinks")
338            .and_then(Json::as_arr)
339            .unwrap_or(&[])
340            .is_empty()
341        {
342            return None;
343        }
344        let id = row.field_u64("id").filter(|id| *id > 0)?;
345        let event = Self {
346            id,
347            origin_id: row
348                .field_u64("origin_id")
349                .filter(|id| *id > 0)
350                .unwrap_or(id),
351            owner: row.field_str("owner")?,
352            from: EntityRef::parse_token(&row.field_str("from")?)?,
353            to: EntityRef::parse_token(&row.field_str("to")?)?,
354            at: row.field_u64("at")?,
355            undone_at: row.field_u64("undone_at"),
356            before: super::from_json(before).ok()?,
357        };
358        if event.from.kind != event.to.kind || !has_records(&event.before) {
359            return None;
360        }
361        let selected = snapshot(&event.before, &event.owner, &event.from);
362        if selected.annotations != event.before.annotations
363            || selected.plays != event.before.plays
364            || selected.counts != event.before.counts
365            || selected.relation_annotations != event.before.relation_annotations
366            || selected.playlists != event.before.playlists
367            || selected.scrobbles != event.before.scrobbles
368        {
369            return None;
370        }
371        Some(event)
372    }
373}
374
375#[cfg(test)]
376#[path = "user_relink_tests.rs"]
377mod tests;