prompt_store.rs

  1mod prompts;
  2
  3use anyhow::{Result, anyhow};
  4use chrono::{DateTime, Utc};
  5use collections::HashMap;
  6use futures::FutureExt as _;
  7use futures::future::Shared;
  8use fuzzy::StringMatchCandidate;
  9use gpui::{
 10    App, AppContext, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, Task,
 11};
 12use heed::{
 13    Database, RoTxn,
 14    types::{SerdeBincode, SerdeJson, Str},
 15};
 16use parking_lot::RwLock;
 17pub use prompts::*;
 18use rope::Rope;
 19use serde::{Deserialize, Serialize};
 20use std::{
 21    cmp::Reverse,
 22    future::Future,
 23    path::PathBuf,
 24    sync::{Arc, atomic::AtomicBool},
 25};
 26use strum::{EnumIter, IntoEnumIterator as _};
 27use text::LineEnding;
 28use util::ResultExt;
 29use uuid::Uuid;
 30
 31/// Init starts loading the PromptStore in the background and assigns
 32/// a shared future to a global.
 33pub fn init(cx: &mut App) {
 34    let db_path = paths::prompts_dir().join("prompts-library-db.0.mdb");
 35    let prompt_store_task = PromptStore::new(db_path, cx);
 36    let prompt_store_entity_task = cx
 37        .spawn(async move |cx| {
 38            prompt_store_task
 39                .await
 40                .and_then(|prompt_store| cx.new(|_cx| prompt_store))
 41                .map_err(Arc::new)
 42        })
 43        .shared();
 44    cx.set_global(GlobalPromptStore(prompt_store_entity_task))
 45}
 46
 47#[derive(Clone, Debug, Serialize, Deserialize)]
 48pub struct PromptMetadata {
 49    pub id: PromptId,
 50    pub title: Option<SharedString>,
 51    pub default: bool,
 52    pub saved_at: DateTime<Utc>,
 53}
 54
 55impl PromptMetadata {
 56    fn builtin(builtin: BuiltInPrompt) -> Self {
 57        Self {
 58            id: PromptId::BuiltIn(builtin),
 59            title: Some(builtin.title().into()),
 60            default: false,
 61            saved_at: DateTime::default(),
 62        }
 63    }
 64}
 65
 66/// Built-in prompts that have default content and can be customized by users.
 67#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
 68pub enum BuiltInPrompt {
 69    CommitMessage,
 70}
 71
 72impl BuiltInPrompt {
 73    pub fn title(&self) -> &'static str {
 74        match self {
 75            Self::CommitMessage => "Commit message",
 76        }
 77    }
 78
 79    /// Returns the default content for this built-in prompt.
 80    pub fn default_content(&self) -> &'static str {
 81        match self {
 82            Self::CommitMessage => include_str!("../../git_ui/src/commit_message_prompt.txt"),
 83        }
 84    }
 85}
 86
 87impl std::fmt::Display for BuiltInPrompt {
 88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 89        match self {
 90            Self::CommitMessage => write!(f, "Commit message"),
 91        }
 92    }
 93}
 94
 95#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 96#[serde(tag = "kind")]
 97pub enum PromptId {
 98    User { uuid: UserPromptId },
 99    BuiltIn(BuiltInPrompt),
100}
101
102impl PromptId {
103    pub fn new() -> PromptId {
104        UserPromptId::new().into()
105    }
106
107    pub fn as_user(&self) -> Option<UserPromptId> {
108        match self {
109            Self::User { uuid } => Some(*uuid),
110            Self::BuiltIn { .. } => None,
111        }
112    }
113
114    pub fn as_built_in(&self) -> Option<BuiltInPrompt> {
115        match self {
116            Self::User { .. } => None,
117            Self::BuiltIn(builtin) => Some(*builtin),
118        }
119    }
120
121    pub fn is_built_in(&self) -> bool {
122        matches!(self, Self::BuiltIn { .. })
123    }
124
125    pub fn can_edit(&self) -> bool {
126        match self {
127            Self::User { .. } => true,
128            Self::BuiltIn(builtin) => match builtin {
129                BuiltInPrompt::CommitMessage => true,
130            },
131        }
132    }
133}
134
135impl From<BuiltInPrompt> for PromptId {
136    fn from(builtin: BuiltInPrompt) -> Self {
137        PromptId::BuiltIn(builtin)
138    }
139}
140
141impl From<UserPromptId> for PromptId {
142    fn from(uuid: UserPromptId) -> Self {
143        PromptId::User { uuid }
144    }
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
148#[serde(transparent)]
149pub struct UserPromptId(pub Uuid);
150
151impl UserPromptId {
152    pub fn new() -> UserPromptId {
153        UserPromptId(Uuid::new_v4())
154    }
155}
156
157impl From<Uuid> for UserPromptId {
158    fn from(uuid: Uuid) -> Self {
159        UserPromptId(uuid)
160    }
161}
162
163impl std::fmt::Display for PromptId {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            PromptId::User { uuid } => write!(f, "{}", uuid.0),
167            PromptId::BuiltIn(builtin) => write!(f, "{}", builtin),
168        }
169    }
170}
171
172pub struct PromptStore {
173    env: heed::Env,
174    metadata_cache: RwLock<MetadataCache>,
175    metadata: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
176    bodies: Database<SerdeJson<PromptId>, Str>,
177}
178
179pub struct PromptsUpdatedEvent;
180
181impl EventEmitter<PromptsUpdatedEvent> for PromptStore {}
182
183#[derive(Default)]
184struct MetadataCache {
185    metadata: Vec<PromptMetadata>,
186    metadata_by_id: HashMap<PromptId, PromptMetadata>,
187}
188
189impl MetadataCache {
190    fn from_db(
191        db: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
192        txn: &RoTxn,
193    ) -> Result<Self> {
194        let mut cache = MetadataCache::default();
195        for result in db.iter(txn)? {
196            // Fail-open: skip records that can't be decoded (e.g. from a different branch)
197            // rather than failing the entire prompt store initialization.
198            let Ok((prompt_id, metadata)) = result else {
199                log::warn!(
200                    "Skipping unreadable prompt record in database: {:?}",
201                    result.err()
202                );
203                continue;
204            };
205            cache.metadata.push(metadata.clone());
206            cache.metadata_by_id.insert(prompt_id, metadata);
207        }
208
209        // Insert all the built-in prompts that were not customized by the user
210        for builtin in BuiltInPrompt::iter() {
211            let builtin_id = PromptId::BuiltIn(builtin);
212            if !cache.metadata_by_id.contains_key(&builtin_id) {
213                let metadata = PromptMetadata::builtin(builtin);
214                cache.metadata.push(metadata.clone());
215                cache.metadata_by_id.insert(builtin_id, metadata);
216            }
217        }
218        cache.sort();
219        Ok(cache)
220    }
221
222    fn insert(&mut self, metadata: PromptMetadata) {
223        self.metadata_by_id.insert(metadata.id, metadata.clone());
224        if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
225            *old_metadata = metadata;
226        } else {
227            self.metadata.push(metadata);
228        }
229        self.sort();
230    }
231
232    fn remove(&mut self, id: PromptId) {
233        self.metadata.retain(|metadata| metadata.id != id);
234        self.metadata_by_id.remove(&id);
235    }
236
237    fn sort(&mut self) {
238        self.metadata.sort_unstable_by(|a, b| {
239            a.title
240                .cmp(&b.title)
241                .then_with(|| b.saved_at.cmp(&a.saved_at))
242        });
243    }
244}
245
246impl PromptStore {
247    pub fn global(cx: &App) -> impl Future<Output = Result<Entity<Self>>> + use<> {
248        let store = GlobalPromptStore::global(cx).0.clone();
249        async move { store.await.map_err(|err| anyhow!(err)) }
250    }
251
252    pub fn new(db_path: PathBuf, cx: &App) -> Task<Result<Self>> {
253        cx.background_spawn(async move {
254            std::fs::create_dir_all(&db_path)?;
255
256            let db_env = unsafe {
257                heed::EnvOpenOptions::new()
258                    .map_size(1024 * 1024 * 1024) // 1GB
259                    .max_dbs(4) // Metadata and bodies (possibly v1 of both as well)
260                    .open(db_path)?
261            };
262
263            let mut txn = db_env.write_txn()?;
264            let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
265            let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
266            txn.commit()?;
267
268            Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
269
270            let txn = db_env.read_txn()?;
271            let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
272            txn.commit()?;
273
274            Ok(PromptStore {
275                env: db_env,
276                metadata_cache: RwLock::new(metadata_cache),
277                metadata,
278                bodies,
279            })
280        })
281    }
282
283    fn upgrade_dbs(
284        env: &heed::Env,
285        metadata_db: heed::Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
286        bodies_db: heed::Database<SerdeJson<PromptId>, Str>,
287    ) -> Result<()> {
288        #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
289        pub struct PromptIdV1(Uuid);
290
291        #[derive(Clone, Debug, Serialize, Deserialize)]
292        pub struct PromptMetadataV1 {
293            pub id: PromptIdV1,
294            pub title: Option<SharedString>,
295            pub default: bool,
296            pub saved_at: DateTime<Utc>,
297        }
298
299        let mut txn = env.write_txn()?;
300        let Some(bodies_v1_db) = env
301            .open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<String>>(
302                &txn,
303                Some("bodies"),
304            )?
305        else {
306            return Ok(());
307        };
308        let mut bodies_v1 = bodies_v1_db
309            .iter(&txn)?
310            .collect::<heed::Result<HashMap<_, _>>>()?;
311
312        let Some(metadata_v1_db) = env
313            .open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<PromptMetadataV1>>(
314                &txn,
315                Some("metadata"),
316            )?
317        else {
318            return Ok(());
319        };
320        let metadata_v1 = metadata_v1_db
321            .iter(&txn)?
322            .collect::<heed::Result<HashMap<_, _>>>()?;
323
324        for (prompt_id_v1, metadata_v1) in metadata_v1 {
325            let prompt_id_v2 = UserPromptId(prompt_id_v1.0).into();
326            let Some(body_v1) = bodies_v1.remove(&prompt_id_v1) else {
327                continue;
328            };
329
330            if metadata_db
331                .get(&txn, &prompt_id_v2)?
332                .is_none_or(|metadata_v2| metadata_v1.saved_at > metadata_v2.saved_at)
333            {
334                metadata_db.put(
335                    &mut txn,
336                    &prompt_id_v2,
337                    &PromptMetadata {
338                        id: prompt_id_v2,
339                        title: metadata_v1.title.clone(),
340                        default: metadata_v1.default,
341                        saved_at: metadata_v1.saved_at,
342                    },
343                )?;
344                bodies_db.put(&mut txn, &prompt_id_v2, &body_v1)?;
345            }
346        }
347
348        txn.commit()?;
349
350        Ok(())
351    }
352
353    pub fn load(&self, id: PromptId, cx: &App) -> Task<Result<String>> {
354        let env = self.env.clone();
355        let bodies = self.bodies;
356        cx.background_spawn(async move {
357            let txn = env.read_txn()?;
358            let mut prompt: String = match bodies.get(&txn, &id)? {
359                Some(body) => body.into(),
360                None => {
361                    if let Some(built_in) = id.as_built_in() {
362                        built_in.default_content().into()
363                    } else {
364                        anyhow::bail!("prompt not found")
365                    }
366                }
367            };
368            LineEnding::normalize(&mut prompt);
369            Ok(prompt)
370        })
371    }
372
373    pub fn all_prompt_metadata(&self) -> Vec<PromptMetadata> {
374        self.metadata_cache.read().metadata.clone()
375    }
376
377    pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
378        return self
379            .metadata_cache
380            .read()
381            .metadata
382            .iter()
383            .filter(|metadata| metadata.default)
384            .cloned()
385            .collect::<Vec<_>>();
386    }
387
388    pub fn delete(&self, id: PromptId, cx: &Context<Self>) -> Task<Result<()>> {
389        self.metadata_cache.write().remove(id);
390
391        let db_connection = self.env.clone();
392        let bodies = self.bodies;
393        let metadata = self.metadata;
394
395        let task = cx.background_spawn(async move {
396            let mut txn = db_connection.write_txn()?;
397
398            metadata.delete(&mut txn, &id)?;
399            bodies.delete(&mut txn, &id)?;
400
401            txn.commit()?;
402            anyhow::Ok(())
403        });
404
405        cx.spawn(async move |this, cx| {
406            task.await?;
407            this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
408            anyhow::Ok(())
409        })
410    }
411
412    pub fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
413        self.metadata_cache.read().metadata_by_id.get(&id).cloned()
414    }
415
416    pub fn first(&self) -> Option<PromptMetadata> {
417        self.metadata_cache.read().metadata.first().cloned()
418    }
419
420    pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
421        let metadata_cache = self.metadata_cache.read();
422        let metadata = metadata_cache
423            .metadata
424            .iter()
425            .find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?;
426        Some(metadata.id)
427    }
428
429    pub fn search(
430        &self,
431        query: String,
432        cancellation_flag: Arc<AtomicBool>,
433        cx: &App,
434    ) -> Task<Vec<PromptMetadata>> {
435        let cached_metadata = self.metadata_cache.read().metadata.clone();
436        let executor = cx.background_executor().clone();
437        cx.background_spawn(async move {
438            let mut matches = if query.is_empty() {
439                cached_metadata
440            } else {
441                let candidates = cached_metadata
442                    .iter()
443                    .enumerate()
444                    .filter_map(|(ix, metadata)| {
445                        Some(StringMatchCandidate::new(ix, metadata.title.as_ref()?))
446                    })
447                    .collect::<Vec<_>>();
448                let matches = fuzzy::match_strings(
449                    &candidates,
450                    &query,
451                    false,
452                    true,
453                    100,
454                    &cancellation_flag,
455                    executor,
456                )
457                .await;
458                matches
459                    .into_iter()
460                    .map(|mat| cached_metadata[mat.candidate_id].clone())
461                    .collect()
462            };
463            matches.sort_by_key(|metadata| Reverse(metadata.default));
464            matches
465        })
466    }
467
468    pub fn save(
469        &self,
470        id: PromptId,
471        title: Option<SharedString>,
472        default: bool,
473        body: Rope,
474        cx: &Context<Self>,
475    ) -> Task<Result<()>> {
476        if !id.can_edit() {
477            return Task::ready(Err(anyhow!("this prompt cannot be edited")));
478        }
479
480        let body = body.to_string();
481        let is_default_content = id
482            .as_built_in()
483            .is_some_and(|builtin| body.trim() == builtin.default_content().trim());
484
485        let metadata = if let Some(builtin) = id.as_built_in() {
486            PromptMetadata::builtin(builtin)
487        } else {
488            PromptMetadata {
489                id,
490                title,
491                default,
492                saved_at: Utc::now(),
493            }
494        };
495
496        self.metadata_cache.write().insert(metadata.clone());
497
498        let db_connection = self.env.clone();
499        let bodies = self.bodies;
500        let metadata_db = self.metadata;
501
502        let task = cx.background_spawn(async move {
503            let mut txn = db_connection.write_txn()?;
504
505            if is_default_content {
506                metadata_db.delete(&mut txn, &id)?;
507                bodies.delete(&mut txn, &id)?;
508            } else {
509                metadata_db.put(&mut txn, &id, &metadata)?;
510                bodies.put(&mut txn, &id, &body)?;
511            }
512
513            txn.commit()?;
514
515            anyhow::Ok(())
516        });
517
518        cx.spawn(async move |this, cx| {
519            task.await?;
520            this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
521            anyhow::Ok(())
522        })
523    }
524
525    pub fn save_metadata(
526        &self,
527        id: PromptId,
528        mut title: Option<SharedString>,
529        default: bool,
530        cx: &Context<Self>,
531    ) -> Task<Result<()>> {
532        let mut cache = self.metadata_cache.write();
533
534        if !id.can_edit() {
535            title = cache
536                .metadata_by_id
537                .get(&id)
538                .and_then(|metadata| metadata.title.clone());
539        }
540
541        let prompt_metadata = PromptMetadata {
542            id,
543            title,
544            default,
545            saved_at: Utc::now(),
546        };
547
548        cache.insert(prompt_metadata.clone());
549
550        let db_connection = self.env.clone();
551        let metadata = self.metadata;
552
553        let task = cx.background_spawn(async move {
554            let mut txn = db_connection.write_txn()?;
555            metadata.put(&mut txn, &id, &prompt_metadata)?;
556            txn.commit()?;
557
558            anyhow::Ok(())
559        });
560
561        cx.spawn(async move |this, cx| {
562            task.await?;
563            this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
564            anyhow::Ok(())
565        })
566    }
567}
568
569/// Wraps a shared future to a prompt store so it can be assigned as a context global.
570pub struct GlobalPromptStore(Shared<Task<Result<Entity<PromptStore>, Arc<anyhow::Error>>>>);
571
572impl Global for GlobalPromptStore {}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use gpui::TestAppContext;
578
579    #[gpui::test]
580    async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) {
581        cx.executor().allow_parking();
582
583        let temp_dir = tempfile::tempdir().unwrap();
584        let db_path = temp_dir.path().join("prompts-db");
585
586        let store = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap();
587        let store = cx.new(|_cx| store);
588
589        let commit_message_id = PromptId::BuiltIn(BuiltInPrompt::CommitMessage);
590
591        let loaded_content = store
592            .update(cx, |store, cx| store.load(commit_message_id, cx))
593            .await
594            .unwrap();
595
596        let mut expected_content = BuiltInPrompt::CommitMessage.default_content().to_string();
597        LineEnding::normalize(&mut expected_content);
598        assert_eq!(
599            loaded_content.trim(),
600            expected_content.trim(),
601            "Loading a built-in prompt not in DB should return default content"
602        );
603
604        let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id));
605        assert!(
606            metadata.is_some(),
607            "Built-in prompt should always have metadata"
608        );
609        assert!(
610            store.read_with(cx, |store, _| {
611                store
612                    .metadata_cache
613                    .read()
614                    .metadata_by_id
615                    .contains_key(&commit_message_id)
616            }),
617            "Built-in prompt should always be in cache"
618        );
619
620        let custom_content = "Custom commit message prompt";
621        store
622            .update(cx, |store, cx| {
623                store.save(
624                    commit_message_id,
625                    Some("Commit message".into()),
626                    false,
627                    Rope::from(custom_content),
628                    cx,
629                )
630            })
631            .await
632            .unwrap();
633
634        let loaded_custom = store
635            .update(cx, |store, cx| store.load(commit_message_id, cx))
636            .await
637            .unwrap();
638        assert_eq!(
639            loaded_custom.trim(),
640            custom_content.trim(),
641            "Custom content should be loaded after saving"
642        );
643
644        assert!(
645            store
646                .read_with(cx, |store, _| store.metadata(commit_message_id))
647                .is_some(),
648            "Built-in prompt should have metadata after customization"
649        );
650
651        store
652            .update(cx, |store, cx| {
653                store.save(
654                    commit_message_id,
655                    Some("Commit message".into()),
656                    false,
657                    Rope::from(BuiltInPrompt::CommitMessage.default_content()),
658                    cx,
659                )
660            })
661            .await
662            .unwrap();
663
664        let metadata_after_reset =
665            store.read_with(cx, |store, _| store.metadata(commit_message_id));
666        assert!(
667            metadata_after_reset.is_some(),
668            "Built-in prompt should still have metadata after reset"
669        );
670        assert_eq!(
671            metadata_after_reset
672                .as_ref()
673                .and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
674            Some("Commit message"),
675            "Built-in prompt should have default title after reset"
676        );
677
678        let loaded_after_reset = store
679            .update(cx, |store, cx| store.load(commit_message_id, cx))
680            .await
681            .unwrap();
682        let mut expected_content_after_reset =
683            BuiltInPrompt::CommitMessage.default_content().to_string();
684        LineEnding::normalize(&mut expected_content_after_reset);
685        assert_eq!(
686            loaded_after_reset.trim(),
687            expected_content_after_reset.trim(),
688            "Content should be back to default after saving default content"
689        );
690    }
691
692    /// Test that the prompt store initializes successfully even when the database
693    /// contains records with incompatible/undecodable PromptId keys (e.g., from
694    /// a different branch that used a different serialization format).
695    ///
696    /// This is a regression test for the "fail-open" behavior: we should skip
697    /// bad records rather than failing the entire store initialization.
698    #[gpui::test]
699    async fn test_prompt_store_handles_incompatible_db_records(cx: &mut TestAppContext) {
700        cx.executor().allow_parking();
701
702        let temp_dir = tempfile::tempdir().unwrap();
703        let db_path = temp_dir.path().join("prompts-db-with-bad-records");
704        std::fs::create_dir_all(&db_path).unwrap();
705
706        // First, create the DB and write an incompatible record directly.
707        // We simulate a record written by a different branch that used
708        // `{"kind":"CommitMessage"}` instead of `{"kind":"BuiltIn", ...}`.
709        {
710            let db_env = unsafe {
711                heed::EnvOpenOptions::new()
712                    .map_size(1024 * 1024 * 1024)
713                    .max_dbs(4)
714                    .open(&db_path)
715                    .unwrap()
716            };
717
718            let mut txn = db_env.write_txn().unwrap();
719            // Create the metadata.v2 database with raw bytes so we can write
720            // an incompatible key format.
721            let metadata_db: Database<heed::types::Bytes, heed::types::Bytes> = db_env
722                .create_database(&mut txn, Some("metadata.v2"))
723                .unwrap();
724
725            // Write an incompatible PromptId key: `{"kind":"CommitMessage"}`
726            // This is the old/branch format that current code can't decode.
727            let bad_key = br#"{"kind":"CommitMessage"}"#;
728            let dummy_metadata = br#"{"id":{"kind":"CommitMessage"},"title":"Bad Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
729            metadata_db.put(&mut txn, bad_key, dummy_metadata).unwrap();
730
731            // Also write a valid record to ensure we can still read good data.
732            let good_key = br#"{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"}"#;
733            let good_metadata = br#"{"id":{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"},"title":"Good Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
734            metadata_db.put(&mut txn, good_key, good_metadata).unwrap();
735
736            txn.commit().unwrap();
737        }
738
739        // Now try to create a PromptStore from this DB.
740        // With fail-open behavior, this should succeed and skip the bad record.
741        // Without fail-open, this would return an error.
742        let store_result = cx.update(|cx| PromptStore::new(db_path, cx)).await;
743
744        assert!(
745            store_result.is_ok(),
746            "PromptStore should initialize successfully even with incompatible DB records. \
747             Got error: {:?}",
748            store_result.err()
749        );
750
751        let store = cx.new(|_cx| store_result.unwrap());
752
753        // Verify the good record was loaded.
754        let good_id = PromptId::User {
755            uuid: UserPromptId("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
756        };
757        let metadata = store.read_with(cx, |store, _| store.metadata(good_id));
758        assert!(
759            metadata.is_some(),
760            "Valid records should still be loaded after skipping bad ones"
761        );
762        assert_eq!(
763            metadata
764                .as_ref()
765                .and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
766            Some("Good Record"),
767            "Valid record should have correct title"
768        );
769    }
770}