@@ -30,6 +30,16 @@ pub fn open(path: &Path, key: Option<&str>) -> rusqlite::Result<Connection> {
3030 conn. pragma_update ( None , "journal_mode" , "WAL" ) ?;
3131 conn. pragma_update ( None , "foreign_keys" , "ON" ) ?;
3232 conn. execute_batch ( SCHEMA ) ?;
33+ // Migration: `kind` postdates v0.5 journals. CREATE TABLE IF NOT EXISTS won't
34+ // touch an existing table, so add the column when it's missing.
35+ let has_kind: i64 = conn. query_row (
36+ "SELECT COUNT(*) FROM pragma_table_info('experiences') WHERE name = 'kind'" ,
37+ [ ] ,
38+ |r| r. get ( 0 ) ,
39+ ) ?;
40+ if has_kind == 0 {
41+ conn. execute_batch ( "ALTER TABLE experiences ADD COLUMN kind TEXT NOT NULL DEFAULT 'session'" ) ?;
42+ }
3343 Ok ( conn)
3444}
3545
@@ -107,6 +117,10 @@ CREATE TABLE IF NOT EXISTS substances (
107117
108118CREATE TABLE IF NOT EXISTS experiences (
109119 id INTEGER PRIMARY KEY,
120+ -- 'session' (a drug session: doses, timeline, the works) or 'note' (a plain
121+ -- journal entry: title, body, date — nothing else). Explicit, never inferred:
122+ -- a session with no doses logged *yet* is still a session.
123+ kind TEXT NOT NULL DEFAULT 'session',
110124 title TEXT NOT NULL DEFAULT '',
111125 intention TEXT NOT NULL DEFAULT '',
112126 setting TEXT NOT NULL DEFAULT '',
@@ -194,6 +208,8 @@ pub struct TimelineEvent {
194208#[ derive( Debug , Clone , Serialize ) ]
195209pub struct Experience {
196210 pub id : i64 ,
211+ /// `'session'` or `'note'`. Set at creation and never changed by edits.
212+ pub kind : String ,
197213 pub title : String ,
198214 pub intention : String ,
199215 pub setting : String ,
@@ -240,6 +256,9 @@ pub struct SubstanceInput {
240256
241257#[ derive( Debug , Deserialize ) ]
242258pub struct ExperienceInput {
259+ /// `'session'` (default) or `'note'` — anything else is treated as `'session'`.
260+ #[ serde( default = "default_kind" ) ]
261+ pub kind : String ,
243262 #[ serde( default ) ]
244263 pub title : String ,
245264 #[ serde( default ) ]
@@ -249,6 +268,10 @@ pub struct ExperienceInput {
249268 pub started_at : String ,
250269}
251270
271+ fn default_kind ( ) -> String {
272+ "session" . into ( )
273+ }
274+
252275#[ derive( Debug , Deserialize ) ]
253276pub struct DoseInput {
254277 pub experience_id : i64 ,
@@ -342,6 +365,7 @@ fn classes_for(conn: &Connection, name: &str) -> Vec<String> {
342365fn row_to_experience ( r : & rusqlite:: Row ) -> rusqlite:: Result < Experience > {
343366 Ok ( Experience {
344367 id : r. get ( "id" ) ?,
368+ kind : r. get ( "kind" ) ?,
345369 title : r. get ( "title" ) ?,
346370 intention : r. get ( "intention" ) ?,
347371 setting : r. get ( "setting" ) ?,
@@ -354,14 +378,33 @@ fn row_to_experience(r: &rusqlite::Row) -> rusqlite::Result<Experience> {
354378}
355379
356380pub fn create_experience ( conn : & Connection , input : & ExperienceInput ) -> rusqlite:: Result < Experience > {
381+ // Normalize, don't validate-and-reject: unknown kinds mean an older client,
382+ // and an older client means a session (that was the only kind there was).
383+ let kind = if input. kind == "note" { "note" } else { "session" } ;
357384 conn. execute (
358- "INSERT INTO experiences (title, intention, setting, started_at) VALUES (?1, ?2, ?3, ?4)" ,
359- params ! [ input. title, input. intention, input. setting, input. started_at] ,
385+ "INSERT INTO experiences (kind, title, intention, setting, started_at) VALUES (?1, ?2, ?3, ?4, ?5 )" ,
386+ params ! [ kind , input. title, input. intention, input. setting, input. started_at] ,
360387 ) ?;
361388 let id = conn. last_insert_rowid ( ) ;
362389 get_experience_row ( conn, id)
363390}
364391
392+ /// Refuse a session-only operation against a plain note. Notes have no doses, no
393+ /// timeline, and no "end": that invariant is enforced here, not hoped for in the UI.
394+ fn require_session ( conn : & Connection , experience_id : i64 , what : & str ) -> rusqlite:: Result < ( ) > {
395+ let kind: String =
396+ conn. query_row ( "SELECT kind FROM experiences WHERE id = ?1" , [ experience_id] , |r| r. get ( 0 ) ) ?;
397+ if kind == "note" {
398+ // SqliteFailure with a message Displays as just the message, which is what
399+ // `Db::with`'s to_string() hands the UI.
400+ return Err ( rusqlite:: Error :: SqliteFailure (
401+ rusqlite:: ffi:: Error :: new ( rusqlite:: ffi:: SQLITE_CONSTRAINT ) ,
402+ Some ( format ! ( "This entry is a plain note, not a session — it can't have {what}." ) ) ,
403+ ) ) ;
404+ }
405+ Ok ( ( ) )
406+ }
407+
365408fn get_experience_row ( conn : & Connection , id : i64 ) -> rusqlite:: Result < Experience > {
366409 conn. query_row ( "SELECT * FROM experiences WHERE id = ?1" , [ id] , row_to_experience)
367410}
@@ -422,6 +465,7 @@ pub fn get_experience(conn: &Connection, id: i64) -> rusqlite::Result<Experience
422465}
423466
424467pub fn end_experience ( conn : & Connection , id : i64 , ended_at : & str , rating : Option < i64 > , notes : & str ) -> rusqlite:: Result < Experience > {
468+ require_session ( conn, id, "an end time or rating" ) ?;
425469 conn. execute (
426470 "UPDATE experiences SET ended_at = ?2, rating = ?3, notes = ?4 WHERE id = ?1" ,
427471 params ! [ id, ended_at, rating, notes] ,
@@ -434,6 +478,7 @@ pub fn end_experience(conn: &Connection, id: i64, ended_at: &str, rating: Option
434478/// Insert a dose and return it together with any interaction warnings against the
435479/// other substances already logged in the same experience.
436480pub fn log_dose ( conn : & Connection , input : & DoseInput ) -> rusqlite:: Result < ( Dose , Vec < crate :: interactions:: Warning > ) > {
481+ require_session ( conn, input. experience_id , "doses" ) ?;
437482 let substance_id: Option < i64 > = conn
438483 . query_row (
439484 "SELECT id FROM substances WHERE name = ?1 COLLATE NOCASE" ,
@@ -490,6 +535,7 @@ pub fn combo_warnings(conn: &Connection, names: &[String]) -> Vec<crate::interac
490535}
491536
492537pub fn add_timeline_event ( conn : & Connection , input : & TimelineInput ) -> rusqlite:: Result < TimelineEvent > {
538+ require_session ( conn, input. experience_id , "timeline events" ) ?;
493539 conn. execute (
494540 "INSERT INTO timeline_events (experience_id, at, note, mood, intensity)
495541 VALUES (?1, ?2, ?3, ?4, ?5)" ,
@@ -781,6 +827,7 @@ mod tests {
781827 } ) . unwrap ( ) ;
782828
783829 let exp = create_experience ( & c, & ExperienceInput {
830+ kind : "session" . into ( ) ,
784831 title : "test" . into ( ) , intention : String :: new ( ) , setting : String :: new ( ) ,
785832 started_at : "2026-01-01T20:00:00Z" . into ( ) ,
786833 } ) . unwrap ( ) ;
@@ -813,6 +860,77 @@ mod tests {
813860 assert_eq ! ( detail. doses. len( ) , 2 ) ;
814861 }
815862
863+ #[ test]
864+ fn plain_notes_are_explicit_and_session_only_ops_refuse_them ( ) {
865+ let c = mem ( ) ;
866+
867+ // Kind is normalized at creation: unknown values mean an older client.
868+ let weird = create_experience ( & c, & ExperienceInput {
869+ kind : "diary" . into ( ) , title : "old client" . into ( ) ,
870+ intention : String :: new ( ) , setting : String :: new ( ) ,
871+ started_at : "2026-07-01T09:00:00Z" . into ( ) ,
872+ } ) . unwrap ( ) ;
873+ assert_eq ! ( weird. kind, "session" ) ;
874+
875+ let note = create_experience ( & c, & ExperienceInput {
876+ kind : "note" . into ( ) , title : "just a day" . into ( ) ,
877+ intention : String :: new ( ) , setting : String :: new ( ) ,
878+ started_at : "2026-07-02T09:00:00Z" . into ( ) ,
879+ } ) . unwrap ( ) ;
880+ assert_eq ! ( note. kind, "note" ) ;
881+
882+ // A note is not a session: no doses, no timeline, no "end".
883+ let dose = log_dose ( & c, & DoseInput {
884+ experience_id : note. id , substance_name : "MDMA" . into ( ) , amount : Some ( 100.0 ) ,
885+ unit : "mg" . into ( ) , route : "oral" . into ( ) , taken_at : "2026-07-02T10:00:00Z" . into ( ) ,
886+ note : String :: new ( ) ,
887+ } ) ;
888+ assert ! ( dose. is_err( ) ) ;
889+ let ev = add_timeline_event ( & c, & TimelineInput {
890+ experience_id : note. id , at : "2026-07-02T10:00:00Z" . into ( ) ,
891+ note : "hm" . into ( ) , mood : String :: new ( ) , intensity : None ,
892+ } ) ;
893+ assert ! ( ev. is_err( ) ) ;
894+ assert ! ( end_experience( & c, note. id, "2026-07-02T11:00:00Z" , Some ( 5 ) , "" ) . is_err( ) ) ;
895+
896+ // Editing the body doesn't flip the kind.
897+ let edited = update_experience ( & c, note. id , & ExperienceUpdate {
898+ title : "just a day" . into ( ) , intention : String :: new ( ) , setting : String :: new ( ) ,
899+ notes : "wrote some words" . into ( ) , rating : None ,
900+ started_at : "2026-07-02T09:00:00Z" . into ( ) , ended_at : None ,
901+ } ) . unwrap ( ) ;
902+ assert_eq ! ( edited. kind, "note" ) ;
903+ }
904+
905+ #[ test]
906+ fn kind_column_is_added_to_pre_v05_journals ( ) {
907+ let dir = std:: env:: temp_dir ( ) . join ( format ! ( "fn-migrate-test-{}" , std:: process:: id( ) ) ) ;
908+ let _ = std:: fs:: remove_dir_all ( & dir) ;
909+ std:: fs:: create_dir_all ( & dir) . unwrap ( ) ;
910+ let path = dir. join ( "journal.db" ) ;
911+
912+ // A journal created before `kind` existed: same table minus the column.
913+ {
914+ let c = Connection :: open ( & path) . unwrap ( ) ;
915+ c. execute_batch (
916+ "CREATE TABLE experiences (
917+ id INTEGER PRIMARY KEY, title TEXT NOT NULL DEFAULT '',
918+ intention TEXT NOT NULL DEFAULT '', setting TEXT NOT NULL DEFAULT '',
919+ notes TEXT NOT NULL DEFAULT '', rating INTEGER,
920+ started_at TEXT NOT NULL, ended_at TEXT,
921+ created_at TEXT NOT NULL DEFAULT (datetime('now')));
922+ INSERT INTO experiences (title, started_at) VALUES ('old', '2026-01-01T00:00:00Z');" ,
923+ ) . unwrap ( ) ;
924+ }
925+
926+ // Reopening through the front door migrates it.
927+ let c = open ( & path, None ) . unwrap ( ) ;
928+ let exp = get_experience_row ( & c, 1 ) . unwrap ( ) ;
929+ assert_eq ! ( exp. kind, "session" , "existing rows keep their meaning" ) ;
930+ drop ( c) ;
931+ let _ = std:: fs:: remove_dir_all ( & dir) ;
932+ }
933+
816934 // Uses the bundled DoseWiki snapshot (no network). MDMA + Tramadol is a
817935 // graded "dangerous" interaction on both sides, so it must surface at log time.
818936 #[ test]
@@ -822,6 +940,7 @@ mod tests {
822940 pw_replace_all ( & mut c, & all) . unwrap ( ) ;
823941
824942 let exp = create_experience ( & c, & ExperienceInput {
943+ kind : "session" . into ( ) ,
825944 title : "t" . into ( ) , intention : String :: new ( ) , setting : String :: new ( ) ,
826945 started_at : "2026-01-01T00:00:00Z" . into ( ) ,
827946 } ) . unwrap ( ) ;
0 commit comments