Skip to main content

memstead_base/engine/
apply_commit.rs

1//! Apply a [`CommitEnvelope`] to the in-memory store.
2//!
3//! Distinct from the engine's git-touching mutation paths
4//! ([`crate::engine::mutation`]): `apply_external_commit` materializes
5//! changes the engine did not author. The wire envelope arrives from
6//! some external producer (the bridge today; future Node / Python
7//! adapters) and replays the per-entity changes against the [`Store`]
8//! without producing a new commit. Routes:
9//!
10//! - WASM clients receive envelopes over the bridge wire and call this
11//!   method to keep their in-memory mirror in step with the server.
12//! - Native test setups use it to seed a known graph state without
13//!   running the full storage pipeline.
14//! - A future replay tool reads a stream of envelopes back from disk
15//!   and reconstructs the graph at a point in history.
16//!
17//! Per-change semantics:
18//!
19//! - `Added` / `Modified` → parse the new body, upsert into the store,
20//!   re-emit explicit relationship edges. Parse failures abort the
21//!   entire envelope (the post-state must be coherent — a partial
22//!   apply would leave the store wedged between two SHAs).
23//! - `Deleted` → drop the entity and cascade its edges.
24//! - `Renamed` → drop the source entity, then parse + upsert the new
25//!   body at the new id. We do *not* call [`Store::rename_node`]
26//!   because the wire content is authoritative — the new body may
27//!   carry a different relationship section than the old one did.
28//!
29//! Schema-validation is *soft*: a parse
30//! failure refuses (the entity can't be loaded at all), but
31//! schema-level warnings (dangling wiki-links, unknown rel-types in
32//! the relationships section) do not refuse — the server is the
33//! authority on schema, the client mirrors what arrives.
34//!
35//! HEAD cursor + subscriber notification: after a successful apply
36//! the engine's cached `last_known_head` for the affected mount
37//! advances to the envelope SHA, and one [`MemChangedEvent`] fires
38//! to every subscriber. Same shape every other mem-advance event
39//! flows through.
40
41use crate::engine::{Engine, EngineError, MemChangedEvent};
42use crate::entity::id::file_path_to_id;
43use crate::entity::loader::parse_entries;
44use crate::entity::source::SourceEntry;
45use crate::entity::store_builder::push_entities_into_store;
46use crate::ops::{CommitEnvelope, EntityChange};
47
48impl Engine {
49    /// Replay an externally-produced commit envelope into the
50    /// in-memory store.
51    ///
52    /// Refuses with [`EngineError::UnknownMem`] when the envelope
53    /// names a mem the engine has no mount for. Parse failures on
54    /// any change variant surface as [`EngineError::Parse`] and abort
55    /// the apply before any store mutation lands — the post-state is
56    /// either coherent at the envelope SHA or unchanged. (The store
57    /// mutation loop below uses a staged scratch list precisely to
58    /// preserve this all-or-nothing property; do not refactor it
59    /// into per-change in-place mutations without restoring an
60    /// equivalent guarantee.)
61    ///
62    /// Empty `changes` is a valid envelope: the head cursor advances
63    /// and a `MemChangedEvent` fires, but no store mutation
64    /// happens. Lets replay drivers signal "we saw a commit, here is
65    /// its SHA, no entity-level changes" — useful for empty commits
66    /// (e.g. tag-only or merge commits without tree changes).
67    pub fn apply_external_commit(&mut self, envelope: &CommitEnvelope) -> Result<(), EngineError> {
68        let mem = envelope.mem.as_str();
69
70        // 1. Resolve mount + schema. Unknown mem refuses before any
71        //    parse work — same idempotent shape as `reload_one_mem`.
72        let mount_idx = self
73            .mounts
74            .iter()
75            .position(|m| m.mount.mem == mem)
76            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
77        let schema = self
78            .schemas
79            .get(mem)
80            .cloned()
81            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
82
83        // 2. Walk the changes. Stage parse outputs so a parse failure
84        //    on a late change rolls back the entire apply (the store
85        //    remains untouched until the second loop below). Deletions
86        //    and rename-source removals stage too — they're just paths.
87        let mut to_upsert: Vec<SourceEntry> = Vec::new();
88        let mut to_remove: Vec<String> = Vec::new();
89        for change in &envelope.changes {
90            match change {
91                EntityChange::Added { path, content }
92                | EntityChange::Modified { path, content } => {
93                    to_upsert.push(SourceEntry {
94                        relative_path: path.clone(),
95                        source_path: std::path::PathBuf::from(path.clone()),
96                        content: content.clone(),
97                    });
98                }
99                EntityChange::Deleted { path } => {
100                    to_remove.push(path.clone());
101                }
102                EntityChange::Renamed { from, to, content } => {
103                    to_remove.push(from.clone());
104                    to_upsert.push(SourceEntry {
105                        relative_path: to.clone(),
106                        source_path: std::path::PathBuf::from(to.clone()),
107                        content: content.clone(),
108                    });
109                }
110            }
111        }
112
113        // 3. Parse the staged adds/modifies/rename-targets against the
114        //    mem schema. Parse errors abort before the store sees a
115        //    mutation. `parse_entries` collects per-file errors into
116        //    `LoadResult::errors`; on `apply_external_commit` semantics
117        //    we treat any parse failure as a hard refuse — the client
118        //    cannot half-apply a commit.
119        let load_result = parse_entries(to_upsert, Vec::new(), mem, schema.as_ref());
120        if let Some((path, msg)) = load_result.errors.into_iter().next() {
121            return Err(EngineError::ParseAfterWrite(format!(
122                "apply_external_commit: failed to parse '{}' in mem '{}': {}",
123                path.display(),
124                mem,
125                msg
126            )));
127        }
128
129        // 4. Mutate the store. Removals first so a rename's
130        //    (from, to) pair where `to == from + suffix-rename`
131        //    doesn't wipe the just-inserted new entity.
132        for path in &to_remove {
133            let id = file_path_to_id(path, mem);
134            self.store.remove(&id);
135        }
136        // Attach file_path on each parsed entity to mirror the
137        // boot/reload contract — handlers that surface
138        // `Entity::file_path` to consumers (e.g. health, export) see
139        // the same value the source pipeline would have produced.
140        let mut parsed = load_result.entities;
141        for result in parsed.iter_mut() {
142            result.entity.file_path = result.entity.id.0.clone();
143        }
144        // Mutation-path call site → no `LoadCollector`; matches the
145        // documented invariant in `push_entities_into_store` (load
146        // sites emit drift warnings, mutation sites stay silent).
147        // The fallback-schema parameter is underscored inside the
148        // helper (no longer consulted for edge emission); the
149        // engine-wide sentinel is the historical pick here.
150        let fallback = crate::engine_fallback_type();
151        push_entities_into_store(&mut self.store, parsed, fallback.as_ref(), None);
152
153        // 5. Advance the cached head + invalidate memos so the next
154        //    read sees the new state.
155        let previous = self
156            .mounts
157            .get(mount_idx)
158            .and_then(|m| m.last_known_head.clone())
159            .unwrap_or_default();
160        if let Some(state) = self.mounts.get_mut(mount_idx) {
161            state.last_known_head = Some(envelope.sha.clone());
162        }
163        self.invalidate_communities();
164        #[cfg(not(target_arch = "wasm32"))]
165        self.invalidate_search_indexes();
166
167        // 6. Emit one `MemChangedEvent` so subscribers see the
168        //    transition. Same shape `record_self_write` and
169        //    `branch_reset` use.
170        if previous != envelope.sha {
171            let event = MemChangedEvent {
172                mem: mem.to_string(),
173                head: envelope.sha.clone(),
174                previous,
175                n_commits: 1,
176            };
177            self.emit_mem_changed(&event);
178        }
179        Ok(())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::backend::MemBackend;
187    use crate::engine::test_helpers::*;
188    use crate::entity::EntityId;
189    use crate::storage::FilesystemMemWriter;
190    use std::collections::BTreeMap;
191    use std::sync::{Arc, Mutex};
192    use tempfile::TempDir;
193
194    fn empty_folder_engine(tmp: &TempDir, mem: &str) -> Engine {
195        let mem_dir = tmp.path().to_path_buf();
196        let writer = FilesystemMemWriter::new(mem_dir.clone());
197        Engine::from_mounts(vec![(
198            folder_mount(mem, mem_dir),
199            Box::new(writer) as Box<dyn MemBackend>,
200        )])
201        .unwrap()
202    }
203
204    fn envelope(mem: &str, sha: &str, parent: &str, changes: Vec<EntityChange>) -> CommitEnvelope {
205        CommitEnvelope {
206            sha: sha.to_string(),
207            parent: parent.to_string(),
208            mem: mem.to_string(),
209            timestamp: "2026-05-19T10:00:00Z".to_string(),
210            trailers: BTreeMap::new(),
211            changes,
212        }
213    }
214
215    fn well_formed_body(title: &str) -> String {
216        format!(
217            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# {title}\n\n## Identity\n\n{title}\n"
218        )
219    }
220
221    #[test]
222    fn apply_external_commit_unknown_mem_refuses() {
223        let tmp = TempDir::new().unwrap();
224        let mut engine = empty_folder_engine(&tmp, "specs");
225        let env = envelope("missing", "deadbeef", "", vec![]);
226        let err = engine.apply_external_commit(&env).unwrap_err();
227        assert_eq!(err.code(), "UNKNOWN_MEM");
228    }
229
230    #[test]
231    fn apply_external_commit_added_lands_entity_in_store() {
232        let tmp = TempDir::new().unwrap();
233        let mut engine = empty_folder_engine(&tmp, "specs");
234        let env = envelope(
235            "specs",
236            "abc123",
237            "",
238            vec![EntityChange::Added {
239                path: "alpha.md".to_string(),
240                content: well_formed_body("Alpha"),
241            }],
242        );
243        engine.apply_external_commit(&env).unwrap();
244        let id = EntityId::new("specs", "alpha");
245        assert!(
246            engine.get_entity(&id).is_some(),
247            "expected 'specs--alpha' in store after Added apply"
248        );
249    }
250
251    #[test]
252    fn apply_external_commit_modified_replaces_existing_body() {
253        let tmp = TempDir::new().unwrap();
254        let mut engine = empty_folder_engine(&tmp, "specs");
255        // First apply seeds the entity.
256        engine
257            .apply_external_commit(&envelope(
258                "specs",
259                "sha1",
260                "",
261                vec![EntityChange::Added {
262                    path: "alpha.md".to_string(),
263                    content: well_formed_body("Alpha-v0"),
264                }],
265            ))
266            .unwrap();
267        // Second apply mutates it.
268        engine
269            .apply_external_commit(&envelope(
270                "specs",
271                "sha2",
272                "sha1",
273                vec![EntityChange::Modified {
274                    path: "alpha.md".to_string(),
275                    content: well_formed_body("Alpha-v1"),
276                }],
277            ))
278            .unwrap();
279        let id = EntityId::new("specs", "alpha");
280        let entity = engine.get_entity(&id).expect("alpha must still exist");
281        assert_eq!(entity.title, "Alpha-v1");
282    }
283
284    #[test]
285    fn apply_external_commit_deleted_removes_entity_from_store() {
286        let tmp = TempDir::new().unwrap();
287        let mut engine = empty_folder_engine(&tmp, "specs");
288        engine
289            .apply_external_commit(&envelope(
290                "specs",
291                "sha1",
292                "",
293                vec![EntityChange::Added {
294                    path: "alpha.md".to_string(),
295                    content: well_formed_body("Alpha"),
296                }],
297            ))
298            .unwrap();
299        engine
300            .apply_external_commit(&envelope(
301                "specs",
302                "sha2",
303                "sha1",
304                vec![EntityChange::Deleted {
305                    path: "alpha.md".to_string(),
306                }],
307            ))
308            .unwrap();
309        let id = EntityId::new("specs", "alpha");
310        assert!(
311            engine.get_entity(&id).is_none(),
312            "expected 'specs--alpha' removed after Deleted apply"
313        );
314    }
315
316    #[test]
317    fn apply_external_commit_renamed_moves_entity_to_new_id() {
318        let tmp = TempDir::new().unwrap();
319        let mut engine = empty_folder_engine(&tmp, "specs");
320        engine
321            .apply_external_commit(&envelope(
322                "specs",
323                "sha1",
324                "",
325                vec![EntityChange::Added {
326                    path: "alpha.md".to_string(),
327                    content: well_formed_body("Alpha"),
328                }],
329            ))
330            .unwrap();
331        engine
332            .apply_external_commit(&envelope(
333                "specs",
334                "sha2",
335                "sha1",
336                vec![EntityChange::Renamed {
337                    from: "alpha.md".to_string(),
338                    to: "alpha-renamed.md".to_string(),
339                    content: well_formed_body("Alpha Renamed"),
340                }],
341            ))
342            .unwrap();
343        let old_id = EntityId::new("specs", "alpha");
344        let new_id = EntityId::new("specs", "alpha-renamed");
345        assert!(engine.get_entity(&old_id).is_none(), "old id must be gone");
346        assert!(
347            engine.get_entity(&new_id).is_some(),
348            "new id must be present"
349        );
350    }
351
352    #[test]
353    fn apply_external_commit_mixed_changes_apply_atomically() {
354        let tmp = TempDir::new().unwrap();
355        let mut engine = empty_folder_engine(&tmp, "specs");
356        // Seed the mem with two entities.
357        engine
358            .apply_external_commit(&envelope(
359                "specs",
360                "sha1",
361                "",
362                vec![
363                    EntityChange::Added {
364                        path: "alpha.md".to_string(),
365                        content: well_formed_body("Alpha"),
366                    },
367                    EntityChange::Added {
368                        path: "beta.md".to_string(),
369                        content: well_formed_body("Beta"),
370                    },
371                ],
372            ))
373            .unwrap();
374        // Mix: delete one, modify the other, add a third.
375        engine
376            .apply_external_commit(&envelope(
377                "specs",
378                "sha2",
379                "sha1",
380                vec![
381                    EntityChange::Deleted {
382                        path: "alpha.md".to_string(),
383                    },
384                    EntityChange::Modified {
385                        path: "beta.md".to_string(),
386                        content: well_formed_body("Beta-v2"),
387                    },
388                    EntityChange::Added {
389                        path: "gamma.md".to_string(),
390                        content: well_formed_body("Gamma"),
391                    },
392                ],
393            ))
394            .unwrap();
395        let alpha = EntityId::new("specs", "alpha");
396        let beta = EntityId::new("specs", "beta");
397        let gamma = EntityId::new("specs", "gamma");
398        assert!(engine.get_entity(&alpha).is_none());
399        assert_eq!(engine.get_entity(&beta).unwrap().title, "Beta-v2");
400        assert!(engine.get_entity(&gamma).is_some());
401    }
402
403    #[test]
404    fn apply_external_commit_permissive_parser_accepts_minimal_body() {
405        // The parse layer used by `apply_external_commit` is
406        // intentionally permissive — `parse_markdown` does not refuse
407        // missing frontmatter or missing titles (those `ParseError`
408        // variants only fire under the strict validator). This is the
409        // documented design: schema validation is soft — the server is
410        // the authority, the client shows what it receives. Pin the
411        // behavior so a future tightening here surfaces as an
412        // intentional decision.
413        let tmp = TempDir::new().unwrap();
414        let mut engine = empty_folder_engine(&tmp, "specs");
415        let bare_body = "# Bare\n\nNo frontmatter, no schema sections.\n";
416        engine
417            .apply_external_commit(&envelope(
418                "specs",
419                "sha1",
420                "",
421                vec![EntityChange::Added {
422                    path: "bare.md".to_string(),
423                    content: bare_body.to_string(),
424                }],
425            ))
426            .unwrap();
427        let id = EntityId::new("specs", "bare");
428        let entity = engine
429            .get_entity(&id)
430            .expect("permissive parser must produce an entity even without frontmatter");
431        assert_eq!(entity.title, "Bare");
432    }
433
434    #[test]
435    fn apply_external_commit_emits_mem_changed_event_to_subscribers() {
436        let tmp = TempDir::new().unwrap();
437        let mut engine = empty_folder_engine(&tmp, "specs");
438        let observed = Arc::new(Mutex::new(Vec::<MemChangedEvent>::new()));
439        let observed_clone = observed.clone();
440        let _handle = engine
441            .subscribe_mem_changes(
442                "specs",
443                Arc::new(move |event| {
444                    observed_clone.lock().unwrap().push(event.clone());
445                }),
446            )
447            .unwrap();
448        engine
449            .apply_external_commit(&envelope(
450                "specs",
451                "sha-new",
452                "",
453                vec![EntityChange::Added {
454                    path: "alpha.md".to_string(),
455                    content: well_formed_body("Alpha"),
456                }],
457            ))
458            .unwrap();
459        let events = observed.lock().unwrap();
460        assert_eq!(events.len(), 1, "expected one MemChangedEvent");
461        assert_eq!(events[0].head, "sha-new");
462        assert_eq!(events[0].mem, "specs");
463        assert_eq!(events[0].n_commits, 1);
464    }
465
466    #[test]
467    fn apply_external_commit_empty_changes_still_advances_head() {
468        let tmp = TempDir::new().unwrap();
469        let mut engine = empty_folder_engine(&tmp, "specs");
470        let observed = Arc::new(Mutex::new(Vec::<MemChangedEvent>::new()));
471        let observed_clone = observed.clone();
472        let _handle = engine
473            .subscribe_mem_changes(
474                "specs",
475                Arc::new(move |event| {
476                    observed_clone.lock().unwrap().push(event.clone());
477                }),
478            )
479            .unwrap();
480        engine
481            .apply_external_commit(&envelope("specs", "empty-commit-sha", "", vec![]))
482            .unwrap();
483        let events = observed.lock().unwrap();
484        assert_eq!(events.len(), 1, "empty envelope must still emit one event");
485        assert_eq!(events[0].head, "empty-commit-sha");
486    }
487}