Files
WhispAssist/src-tauri/src/storage/mod.rs
T

2503 lines
94 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Storage service (Phase 2, FR-STORE-*, FR-REL-*). SQLite index + on-disk
//! audio/transcript/notes files (ADR-0006). Async via sqlx.
//!
//! Invariant: audio is the source of truth. Derived artifacts (transcript/notes/
//! summary) are regenerable; retention never touches an in-progress meeting.
use crate::models::{
ActionItem, CalendarEvent, ContextExcerpt, FeatureBriefInfo, ImportedEvent, McpAccessEntry,
MeetingId, MeetingListItem, MeetingStatus, Participant, SearchHit, SpeakerInfo,
TranscriptSegment,
};
use crate::paths;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::Row;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("db error: {0}")]
Db(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("not found: {0}")]
NotFound(String),
}
impl From<sqlx::Error> for StoreError {
fn from(e: sqlx::Error) -> Self {
StoreError::Db(e.to_string())
}
}
pub struct NewMeeting {
pub title: String,
pub calendar_event_id: Option<String>,
/// Note-template id (Phase 8, T8.1, FR-NOTE-5) — resolved against
/// `notes::templates::catalog()`, kept alongside the meeting so
/// re-rendering notes.md on reprocess/resume reapplies the same
/// section structure instead of losing it.
pub template_id: Option<String>,
/// Transcription language requested at recording start (T8.7, FR-TRX-4):
/// `None` means auto-detect. Recorded immediately (not just at
/// `finalize_meeting`) so a crash-recovered `recovering` meeting still
/// knows what was asked for; `finalize_meeting`'s `language` overwrites
/// this with whatever whisper.cpp actually resolved/detected.
pub language: Option<String>,
}
/// `list_meetings` filters (Phase 8, FR-SEARCH-2). All fields are ANDed
/// together; each is skipped when `None`.
#[derive(Debug, Default)]
pub struct MeetingFilter {
/// Title substring match — separate from full-text `search()`.
pub query: Option<String>,
pub tag: Option<String>,
/// Matches either a speaker mapped to this participant or an attendee of
/// the meeting's linked calendar event.
pub participant_id: Option<String>,
pub from: Option<i64>,
pub to: Option<i64>,
}
/// What actually happened during the session — richer than the doc's bare
/// `&MeetingId` because `finalize_meeting` has to persist what was captured.
pub struct FinalizeMeeting {
pub segments: Vec<TranscriptSegment>,
pub speakers: Vec<SpeakerInfo>,
pub duration_secs: i64,
pub recorded: bool,
pub language: Option<String>,
pub backend_used: Option<String>,
pub model_used: Option<String>,
/// How `audio.wav`'s channels are laid out (FR-SPK/FR-CAP): `"split"` =
/// stereo mic-left/loopback-right, `"summed"`/`None` = mic mixed into every
/// channel. `None` leaves the stored value unchanged (e.g. on reprocess).
pub audio_layout: Option<String>,
}
/// Full meeting detail: DB row + transcript + speakers + notes (`get_meeting`'s
/// documented contract — `docs/04-api-contracts.md` — which the old
/// `MeetingListItem`-only stub couldn't represent).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Meeting {
pub id: MeetingId,
pub title: String,
pub started_at: i64,
pub ended_at: Option<i64>,
pub duration_secs: Option<i64>,
pub status: MeetingStatus,
pub recorded: bool,
pub language: Option<String>,
pub backend_used: Option<String>,
pub model_used: Option<String>,
/// `audio.wav` channel layout (FR-SPK/FR-CAP): `"split"` (mic-left /
/// loopback-right) or `"summed"`/`None` (mic mixed into every channel).
/// Diarization, playback downmix, and bundle export branch on it.
pub audio_layout: Option<String>,
pub segments: Vec<TranscriptSegment>,
pub speakers: Vec<SpeakerInfo>,
pub notes_markdown: String,
/// `None` until `generate_summary` has run at least once (Phase 5, FR-LLM-4).
pub summary: Option<SummaryFile>,
/// The linked calendar event, if any (T6.3/T6.6, FR-CAL-2/4) — set either
/// at `start_recording` time or later via `attach_meeting_to_event`.
pub calendar_event_id: Option<String>,
/// Tags (Phase 8, FR-SEARCH-2), sorted.
pub tags: Vec<String>,
/// Note-template id (Phase 8, T8.1, FR-NOTE-5), if one was picked at
/// creation — resolved against `notes::templates::catalog()`.
pub template_id: Option<String>,
/// Confirmed/edited action items from the `action_items` table — the
/// source of truth the user manages directly (add/edit/delete via
/// `confirm_action_items`, FR-LLM-3). Falls back to the drafts parsed
/// into `summary.json` when the table has none yet, so a freshly
/// generated summary still shows its suggestions.
pub action_items: Vec<ActionItem>,
}
/// A calendar event with its attendees (T6.3/T6.4, FR-CAL-1/3) — the
/// "detail" fetch behind the pre-meeting context panel and the
/// participant-aware speaker-naming dropdown (T6.5).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarEventDetail {
pub event: CalendarEvent,
pub participants: Vec<Participant>,
}
/// Result of `cleanup_calendar_events` — `protected` is always reported
/// alongside `deleted` so the UI can show the user their meeting-linked
/// events weren't touched, not just a silent lower-than-expected count.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarCleanupResult {
pub deleted: u32,
pub protected: u32,
}
/// On-disk shape of `summary.json` (`docs/03-data-model.md`). Drafted action
/// items here are NOT yet rows in the `action_items` table — the user
/// reviews/edits them first; `confirm_action_items` is what persists them
/// (FR-LLM-3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryFile {
pub schema: u32,
pub generated_at: i64,
pub provider: String,
pub model: String,
pub summary_md: String,
pub decisions: Vec<String>,
pub action_items: Vec<ActionItem>,
}
pub struct Retention {
pub max_age_days: Option<u32>,
pub max_size_gb: Option<u32>,
}
/// A feature brief distilled from a meeting (ADR-0011, M1). Holds only a
/// `credential_ref`-style pointer (`path`) into the meeting's `briefs/`
/// folder — the JSON body is the source of truth; this row is the index
/// `list_feature_briefs`/the MCP scope check reads. Maps 1:1 to the
/// `feature_briefs` table.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct FeatureBriefRow {
pub id: String,
pub meeting_id: MeetingId,
pub title: String,
pub target_repo: Option<String>,
pub path: String, // briefs/<id>.json, relative to the meeting's folder
pub exposed: bool,
pub created_at: i64,
}
impl From<FeatureBriefRow> for FeatureBriefInfo {
fn from(row: FeatureBriefRow) -> Self {
FeatureBriefInfo {
id: row.id,
meeting_id: row.meeting_id,
title: row.title,
target_repo: row.target_repo,
exposed: row.exposed,
}
}
}
/// On-disk shape of `briefs/<id>.json` (`docs/03-data-model.md`, ADR-0011) —
/// the schema/provenance envelope around the same fields as the IPC
/// `FeatureBrief` (`models.rs`). Written by `create_feature_brief` (M1.4),
/// sealed at rest with the vault when unlocked, exactly like `summary.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BriefFile {
pub schema: u32,
pub id: String,
pub meeting_id: MeetingId,
pub generated_at: i64,
pub provider: String,
pub model: String,
pub title: String,
pub problem: String,
pub desired_outcome: String,
pub acceptance_criteria: Vec<String>,
pub target_repo: Option<String>,
pub context_excerpts: Vec<ContextExcerpt>,
pub source: BriefSource,
}
/// `schema`-envelope companion recording which meeting this brief came from,
/// by name and time — distinct from the FK `meeting_id`, which can outlive a
/// renamed/retitled meeting.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BriefSource {
pub meeting_title: String,
pub at: i64,
}
/// A configured upload destination as stored in the DB (ADR-0010). Holds only a
/// `credential_ref` into the OS credential store — never the secret itself
/// (FR-SYNC-6). Maps 1:1 to the `sync_targets` table.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct SyncTargetRow {
pub id: String,
pub name: String,
pub kind: String, // webdav|onedrive|dropbox|box
pub provider_hint: Option<String>,
pub base_url: Option<String>,
pub remote_base_path: String,
pub username: Option<String>,
pub credential_ref: String,
pub enabled: bool,
pub upload_transcript: bool,
pub upload_notes: bool,
pub upload_summary: bool,
pub upload_recording: bool,
pub trigger_on_finalize: bool,
pub allow_plaintext_lan: bool,
pub encrypt_before_upload: bool,
pub created_at: i64,
}
/// One durable upload job (per artifact × per target). Maps to `sync_jobs`.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct SyncJobRow {
pub id: String,
pub target_id: String,
pub meeting_id: MeetingId,
pub artifact: String, // transcript|notes|summary|recording
pub local_path: String,
pub remote_path: String,
pub sha256: Option<String>,
pub status: String, // pending|uploading|done|failed|skipped
pub attempts: i64,
pub last_error: Option<String>,
pub next_attempt_at: Option<i64>,
pub bytes_total: Option<i64>,
pub bytes_sent: i64,
pub updated_at: i64,
}
#[async_trait]
pub trait Store: Send + Sync {
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
/// Returns the meeting's `template_id` (set at creation, Phase 8 T8.1) so
/// callers can re-render notes.md with the same section structure
/// without a separate fetch.
async fn finalize_meeting(
&self,
id: &MeetingId,
s: FinalizeMeeting,
) -> Result<Option<String>, StoreError>;
async fn list_meetings(
&self,
filter: MeetingFilter,
) -> Result<Vec<MeetingListItem>, StoreError>;
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
/// Overwrite a meeting's lifecycle `status` (e.g. mark a background import
/// `transcribing` while it runs, or `error` if it fails). `finalize_meeting`
/// is still the only path to `ready`.
async fn set_meeting_status(&self, id: &MeetingId, status: &str) -> Result<(), StoreError>;
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
/// (Re)builds this meeting's FTS index row from the current title and
/// whatever's on disk/in the DB for transcript/notes/summary/tags (Phase
/// 8, FR-SEARCH-1). `finalize_meeting`/`update_notes`/`set_tags` already
/// call this themselves; callers that write searchable content some
/// other way (e.g. `generate_summary` sealing `summary.json` straight to
/// disk) must call it afterward so search doesn't silently miss it.
async fn reindex_fts(&self, id: &MeetingId) -> Result<(), StoreError>;
/// Set (or create) a speaker's display name; works whether or not the
/// meeting has finalized yet (T4.4, FR-SPK-2/5).
async fn rename_speaker(
&self,
id: &MeetingId,
label: &str,
name: &str,
) -> Result<(), StoreError>;
/// Delete every `speakers` row for a meeting (labels, names, participant
/// links, merges). Used before a full re-diarization rebuild
/// (`reprocess_transcript`, FR-SPK): the old labels key to the previous
/// clustering and are meaningless once the audio is re-clustered, so the
/// caller re-inserts the fresh set via `finalize_meeting`. Without this,
/// stale labels from an over-split run linger in the DB (and the
/// Participants pane) even though the transcript no longer references them.
async fn clear_speakers(&self, id: &MeetingId) -> Result<(), StoreError>;
/// Fold over-split speaker labels into one canonical label (T4.5,
/// FR-SPK-3). Segment speaker IDs in storage are never rewritten
/// (FR-SPK-5) — `get_meeting` resolves `from` labels to `into` when it
/// reads segments/speakers back.
async fn merge_speakers(
&self,
id: &MeetingId,
from: &[String],
into: &str,
) -> Result<(), StoreError>;
/// Persist reviewed/edited action items as confirmed tasks (T5.6,
/// FR-LLM-3): items with an `id` update that row, items without one
/// insert a new row. Drafted (unconfirmed) items from `generate_summary`
/// only ever live in `summary.json` until this is called. Returns the
/// saved items with `id` populated (Phase 8, T8.6) — new rows don't have
/// one yet at call time, but the caller needs it to key reminder scheduling.
async fn save_action_items(
&self,
id: &MeetingId,
items: &[ActionItem],
) -> Result<Vec<ActionItem>, StoreError>;
/// Persist imported calendar events + their attendees (T6.1/T6.2,
/// FR-CAL-1). Re-importing the same event (matched by `source` +
/// `raw_uid`) updates it in place rather than duplicating it. Returns
/// the number of events imported/updated.
async fn import_calendar_events(&self, events: Vec<ImportedEvent>) -> Result<u32, StoreError>;
/// Browse imported calendar events (T6.3, FR-CAL-2), optionally bounded
/// by unix-epoch start time.
async fn list_calendar_events(
&self,
from: Option<i64>,
to: Option<i64>,
) -> Result<Vec<CalendarEvent>, StoreError>;
/// A single event with its attendees (T6.3/T6.4, FR-CAL-1/3) — backs the
/// pre-meeting context panel and the speaker-naming attendee dropdown.
async fn get_calendar_event(&self, id: &str) -> Result<CalendarEventDetail, StoreError>;
/// Deletes imported calendar events not linked to any meeting —
/// `older_than_unix: None` deletes every unlinked event ("Delete all"),
/// `Some(cutoff)` only those with `starts_at < cutoff`. An event
/// referenced by `meetings.calendar_event_id` is always kept regardless
/// of age (FR-CAL-2 pre-meeting context / FR-CAL-4 continuity would
/// break otherwise). Returns `(deleted, protected)`.
async fn cleanup_calendar_events(
&self,
older_than_unix: Option<i64>,
) -> Result<(u32, u32), StoreError>;
/// Link a meeting (current or historical) to a calendar event (T6.3/T6.6,
/// FR-CAL-2/4). Errs if either id doesn't exist. Also mirrors the event's
/// subject onto the meeting's title when it has one — linking is meant to
/// say "this recording is that meeting," so a recording still sitting at
/// its default "Untitled meeting" name should follow it.
async fn attach_meeting_to_event(
&self,
meeting_id: &MeetingId,
event_id: &str,
) -> Result<(), StoreError>;
/// Manually rename a meeting — recordings otherwise default to "Untitled
/// meeting" with no other way to change that.
async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError>;
/// Overwrite a meeting's start/end timestamps — used by bundle import
/// (FR-STORE-4) so a moved recording keeps its original date rather than
/// showing the import time (`create_meeting`/`finalize_meeting` both stamp
/// "now").
async fn set_meeting_times(
&self,
meeting_id: &MeetingId,
started_at: i64,
ended_at: Option<i64>,
) -> Result<(), StoreError>;
/// Names a speaker AND links them to a known `Participant` (T6.5/T6.6,
/// FR-SPK-4): the display name comes from the participant record, and
/// the shared `participant_id` is what gives naming "continuity" across
/// meetings — the same person, once identified anywhere, shows up
/// pre-resolvable wherever they're an attendee again.
async fn map_speaker_to_participant(
&self,
meeting_id: &MeetingId,
label: &str,
participant_id: &str,
) -> Result<(), StoreError>;
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1).
async fn search(&self, query: &str) -> Result<Vec<SearchHit>, StoreError>;
/// Replaces this meeting's complete tag set (Phase 8, FR-SEARCH-2) —
/// not incremental add/remove. Unknown tag names are created.
async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError>;
/// All known tag names, sorted, for filter/autocomplete UI.
async fn list_tags(&self) -> Result<Vec<String>, StoreError>;
/// Action items with an unfired reminder due in the future (Phase 8,
/// T8.6, FR-CAL-5) — a startup reconcile against Windows' scheduled-toast
/// list, since that's the actual source of truth for "already scheduled"
/// (see `reminders` module) and it can be cleared out from under the app
/// (uninstall/reinstall, a different AppUserModelID, etc).
async fn list_pending_reminders(&self) -> Result<Vec<ActionItem>, StoreError>;
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
/// Enforce retention; returns count removed. Skips in-progress meetings
/// (anything whose `status` isn't a terminal `ready`/`error`).
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>;
// ---- Sync targets (Phase 9, ADR-0010) ----
async fn add_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError>;
/// Full-row replace (caller loads, edits, and preserves `credential_ref`).
async fn update_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError>;
async fn remove_sync_target(&self, id: &str) -> Result<(), StoreError>;
async fn list_sync_targets(&self) -> Result<Vec<SyncTargetRow>, StoreError>;
async fn get_sync_target(&self, id: &str) -> Result<SyncTargetRow, StoreError>;
// ---- Sync jobs / durable queue (Phase 9, T9.5) ----
/// Enqueue (or re-enqueue) a job. Idempotent per (target, meeting, artifact):
/// a job already `done` with the same `sha256` is left untouched (skip-if-
/// unchanged); anything else is reset to `pending`. Returns true if enqueued.
async fn upsert_sync_job(&self, job: SyncJobRow) -> Result<bool, StoreError>;
/// Jobs ready to run now: `pending`/`failed` with no future `next_attempt_at`.
async fn claim_due_sync_jobs(&self, now: i64) -> Result<Vec<SyncJobRow>, StoreError>;
async fn update_sync_job(&self, job: SyncJobRow) -> Result<(), StoreError>;
async fn get_sync_job(&self, id: &str) -> Result<SyncJobRow, StoreError>;
async fn list_sync_jobs(
&self,
meeting_id: Option<&MeetingId>,
) -> Result<Vec<SyncJobRow>, StoreError>;
// ---- Feature briefs (Phase 10 M1, ADR-0011) ----
/// Indexes a brief already sealed to disk by `create_feature_brief`
/// (M1.4). Deletion cascades via the meeting FK — `delete_meeting`
/// already drops the row (and its folder) with the rest of the meeting.
async fn insert_feature_brief(&self, row: FeatureBriefRow) -> Result<(), StoreError>;
/// Newest first; `None` lists across all meetings (the MCP "recent
/// briefs" surface and the frontend's per-meeting list share this call).
async fn list_feature_briefs(
&self,
meeting_id: Option<&MeetingId>,
) -> Result<Vec<FeatureBriefInfo>, StoreError>;
/// The full row (incl. `path`) so a caller can resolve and read the
/// sealed JSON file itself (`get_feature_brief`, MCP `get_feature_brief`
/// tool).
async fn get_feature_brief_row(&self, id: &str) -> Result<FeatureBriefRow, StoreError>;
/// Scope control: include/exclude a brief from the MCP server (FR-MCP-3).
async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError>;
// ---- MCP server (Phase 10b, ADR-0011) ----
/// Confirmed action items for a meeting (FR-MCP-2 `get_action_items`) —
/// distinct from `list_pending_reminders` (which is filtered to
/// unfired-reminder rows across *all* meetings for the startup reconcile).
async fn list_action_items(
&self,
meeting_id: &MeetingId,
) -> Result<Vec<ActionItem>, StoreError>;
/// Appends one row to the audit log (FR-MCP-5). Every MCP tool read calls
/// this, regardless of whether the read was actually allowed to see
/// anything — the audit trail is "what an agent asked for", not just
/// "what it received".
async fn record_mcp_access(
&self,
tool: &str,
meeting_id: Option<&MeetingId>,
client: Option<&str>,
) -> Result<(), StoreError>;
/// Most recent audit rows first, optionally capped (`mcp_access_log` command).
async fn list_mcp_access_log(
&self,
limit: Option<u32>,
) -> Result<Vec<McpAccessEntry>, StoreError>;
}
/// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`).
pub struct SqliteStore {
pool: SqlitePool,
}
impl SqliteStore {
pub async fn connect() -> Result<Self, StoreError> {
let root = paths::wa_root();
std::fs::create_dir_all(&root)?;
let opts = SqliteConnectOptions::new()
.filename(paths::db_path())
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(opts)
.await?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|e| StoreError::Db(e.to_string()))?;
let store = Self { pool };
store.backfill_fts().await?;
Ok(store)
}
/// In-memory store for tests: one connection, migrations applied, foreign
/// keys off so sync-queue tests can insert jobs without full meeting rows.
#[cfg(test)]
pub async fn connect_in_memory() -> Result<Self, StoreError> {
let opts = SqliteConnectOptions::new().filename(":memory:");
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|e| StoreError::Db(e.to_string()))?;
sqlx::query("PRAGMA foreign_keys = OFF")
.execute(&pool)
.await
.ok();
Ok(Self { pool })
}
/// One-time-per-meeting catch-up for meetings finalized before full-text
/// search existed (T8.2, FR-SEARCH-1) — `finalize_meeting`/`update_notes`
/// keep `meeting_fts` current for everything from here on, but that adds
/// nothing retroactively for meetings that predate this feature. Cheap
/// and idempotent: only touches meetings missing a row, so after the
/// first run this is a no-op scan on every later startup.
async fn backfill_fts(&self) -> Result<(), StoreError> {
let missing: Vec<String> = sqlx::query_scalar(
"SELECT id FROM meetings WHERE id NOT IN (SELECT meeting_id FROM meeting_fts)",
)
.fetch_all(&self.pool)
.await?;
for id in missing {
if let Err(e) = self.reindex_fts(&id).await {
tracing::warn!("FTS backfill skipped meeting {id}: {e}");
}
}
Ok(())
}
}
impl SqliteStore {
/// `participant_id: None` leaves an existing link untouched (e.g. a
/// plain free-text `rename_speaker` after `map_speaker_to_participant`
/// must not silently unlink the participant) — `COALESCE(excluded.…, …)`
/// falls back to the row's current value when the new one isn't given.
async fn upsert_speaker(
&self,
meeting_id: &MeetingId,
label: &str,
display_name: Option<&str>,
participant_id: Option<&str>,
) -> Result<(), StoreError> {
sqlx::query(
"INSERT INTO speakers (id, meeting_id, label, display_name, participant_id)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(meeting_id, label) DO UPDATE SET
display_name = excluded.display_name,
participant_id = COALESCE(excluded.participant_id, participant_id)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(meeting_id)
.bind(label)
.bind(display_name)
.bind(participant_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Finds-or-creates a `participants` row. Not a plain `ON CONFLICT`
/// upsert: `UNIQUE(name, email)` treats two NULL emails as distinct under
/// standard SQL NULL semantics, so a conflict target wouldn't reliably
/// catch a repeat import of the same no-email attendee — this looks the
/// row up explicitly instead.
async fn upsert_participant(
&self,
name: &str,
email: Option<&str>,
) -> Result<String, StoreError> {
let existing: Option<String> = match email {
Some(email) => sqlx::query("SELECT id FROM participants WHERE name = ? AND email = ?")
.bind(name)
.bind(email)
.fetch_optional(&self.pool)
.await?
.map(|r| r.get("id")),
None => sqlx::query("SELECT id FROM participants WHERE name = ? AND email IS NULL")
.bind(name)
.fetch_optional(&self.pool)
.await?
.map(|r| r.get("id")),
};
if let Some(id) = existing {
return Ok(id);
}
let id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO participants (id, name, email) VALUES (?, ?, ?)")
.bind(&id)
.bind(name)
.bind(email)
.execute(&self.pool)
.await?;
Ok(id)
}
/// Finds-or-creates a `tags` row by name (Phase 8, FR-SEARCH-2). `name` is
/// `UNIQUE`, so — unlike `upsert_participant`'s nullable `email` — a plain
/// `INSERT OR IGNORE` conflict target is unambiguous here.
async fn upsert_tag(&self, name: &str) -> Result<String, StoreError> {
if let Some(id) = sqlx::query_scalar("SELECT id FROM tags WHERE name = ?")
.bind(name)
.fetch_optional(&self.pool)
.await?
{
return Ok(id);
}
let id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT OR IGNORE INTO tags (id, name) VALUES (?, ?)")
.bind(&id)
.bind(name)
.execute(&self.pool)
.await?;
// Someone else (a concurrent set_tags call) may have won the
// INSERT OR IGNORE race; re-look-up rather than assume our id landed.
sqlx::query_scalar("SELECT id FROM tags WHERE name = ?")
.bind(name)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}
/// Tag names for one meeting, sorted (Phase 8, FR-SEARCH-2).
async fn tags_for_meeting(&self, meeting_id: &MeetingId) -> Result<Vec<String>, StoreError> {
Ok(sqlx::query_scalar(
"SELECT t.name FROM tags t
JOIN meeting_tags mt ON mt.tag_id = t.id
WHERE mt.meeting_id = ?
ORDER BY t.name",
)
.bind(meeting_id)
.fetch_all(&self.pool)
.await?)
}
}
/// Follows a `label -> merged_into` chain to its canonical label. Capped at 8
/// hops so a stale/cyclic mapping (shouldn't happen, but merges are
/// user-driven data) can't loop forever; merges normally resolve in one hop.
fn resolve_canonical<'a>(label: &'a str, merge_map: &'a HashMap<String, String>) -> &'a str {
let mut current = label;
for _ in 0..8 {
match merge_map.get(current) {
Some(next) if next != current => current = next.as_str(),
_ => break,
}
}
current
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Turns free-text user input into a safe FTS5 MATCH query (Phase 8,
/// FR-SEARCH-1). FTS5's query syntax gives special meaning to `" - * AND OR
/// NOT`, so passing a search box's raw text straight to MATCH can either
/// throw a syntax error on stray punctuation or search less literally than
/// the user typed. Quoting each token as its own phrase (doubling embedded
/// quotes) makes every token a literal match, ANDed together by default.
fn fts_query_from_input(input: &str) -> String {
input
.split_whitespace()
.map(|tok| format!("\"{}\"", tok.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" ")
}
/// On-disk shape of `transcript.json` (`docs/03-data-model.md`).
#[derive(Debug, Serialize, Deserialize, Default)]
struct TranscriptFile {
schema: u32,
meeting_id: MeetingId,
language: Option<String>,
model: Option<String>,
backend: Option<String>,
segments: Vec<TranscriptSegment>,
speakers: Vec<TranscriptSpeaker>,
}
#[derive(Debug, Serialize, Deserialize)]
struct TranscriptSpeaker {
label: String,
display_name: Option<String>,
}
/// Builds everything but `tags` — the caller fills that in with an async
/// `tags_for_meeting` lookup, since sqlx rows can't be read across an await.
fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem {
MeetingListItem {
id: row.get("id"),
title: row.get("title"),
started_at: row.get("started_at"),
duration_secs: row.get("duration_secs"),
status: MeetingStatus::parse(row.get::<String, _>("status").as_str()),
tags: Vec::new(),
}
}
fn row_to_calendar_event(row: &sqlx::sqlite::SqliteRow) -> CalendarEvent {
CalendarEvent {
id: row.get("id"),
source: row.get("source"),
subject: row.get("subject"),
organizer: row.get("organizer"),
starts_at: row.get("starts_at"),
ends_at: row.get("ends_at"),
description: row.get("description"),
raw_uid: row.get("raw_uid"),
}
}
#[async_trait]
impl Store for SqliteStore {
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError> {
let id = uuid::Uuid::new_v4().to_string();
let folder = paths::meeting_dir(&id);
std::fs::create_dir_all(&folder)?;
let audio_path = folder.join("audio.wav");
let now = now_unix();
sqlx::query(
"INSERT INTO meetings (id, title, started_at, folder_path, audio_path, status, calendar_event_id, template_id, language, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'recording', ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&m.title)
.bind(now)
.bind(folder.display().to_string())
.bind(audio_path.display().to_string())
.bind(&m.calendar_event_id)
.bind(&m.template_id)
.bind(&m.language)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
Ok(id)
}
async fn finalize_meeting(
&self,
id: &MeetingId,
s: FinalizeMeeting,
) -> Result<Option<String>, StoreError> {
let now = now_unix();
sqlx::query(
"UPDATE meetings SET status = 'ready', ended_at = ?, duration_secs = ?, recorded = ?,
language = ?, backend_used = ?, model_used = ?,
audio_layout = COALESCE(?, audio_layout), updated_at = ? WHERE id = ?",
)
.bind(now)
.bind(s.duration_secs)
.bind(s.recorded as i64)
.bind(&s.language)
.bind(&s.backend_used)
.bind(&s.model_used)
.bind(&s.audio_layout)
.bind(now)
.bind(id)
.execute(&self.pool)
.await?;
for speaker in &s.speakers {
self.upsert_speaker(
id,
&speaker.label,
speaker.display_name.as_deref(),
speaker.participant_id.as_deref(),
)
.await?;
}
let transcript = TranscriptFile {
schema: 1,
meeting_id: id.clone(),
language: s.language.clone(),
model: s.model_used.clone(),
backend: s.backend_used.clone(),
segments: s.segments,
speakers: s
.speakers
.iter()
.map(|sp| TranscriptSpeaker {
label: sp.label.clone(),
display_name: sp.display_name.clone(),
})
.collect(),
};
let json =
serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?;
write_artifact(
&paths::meeting_dir(id).join("transcript.json"),
json.as_bytes(),
)?;
self.reindex_fts(id).await?;
let template_id: Option<String> =
sqlx::query_scalar("SELECT template_id FROM meetings WHERE id = ?")
.bind(id)
.fetch_one(&self.pool)
.await?;
Ok(template_id)
}
async fn list_meetings(
&self,
filter: MeetingFilter,
) -> Result<Vec<MeetingListItem>, StoreError> {
// Conditions are pushed in a fixed order (tag, query, participant,
// from, to) and bound in that exact same order below — there's no
// named-parameter binding in this sqlx query style, so the two lists
// must stay in lockstep.
let mut sql = String::from("SELECT DISTINCT m.id, m.title, m.started_at, m.duration_secs, m.status FROM meetings m");
if filter.tag.is_some() {
sql.push_str(
" JOIN meeting_tags mt ON mt.meeting_id = m.id JOIN tags t ON t.id = mt.tag_id",
);
}
let mut conditions = Vec::new();
if filter.tag.is_some() {
conditions.push("t.name = ?");
}
let query = filter.query.filter(|q| !q.trim().is_empty());
if query.is_some() {
conditions.push("m.title LIKE ?");
}
if filter.participant_id.is_some() {
conditions.push(
"(EXISTS (SELECT 1 FROM speakers s WHERE s.meeting_id = m.id AND s.participant_id = ?)
OR EXISTS (SELECT 1 FROM calendar_event_participants cep WHERE cep.calendar_event_id = m.calendar_event_id AND cep.participant_id = ?))",
);
}
if filter.from.is_some() {
conditions.push("m.started_at >= ?");
}
if filter.to.is_some() {
conditions.push("m.started_at <= ?");
}
if !conditions.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conditions.join(" AND "));
}
sql.push_str(" ORDER BY m.started_at DESC");
let mut q = sqlx::query(&sql);
if let Some(tag) = &filter.tag {
q = q.bind(tag);
}
if let Some(query) = &query {
q = q.bind(format!("%{}%", query.trim()));
}
if let Some(pid) = &filter.participant_id {
q = q.bind(pid).bind(pid);
}
if let Some(from) = filter.from {
q = q.bind(from);
}
if let Some(to) = filter.to {
q = q.bind(to);
}
let rows = q.fetch_all(&self.pool).await?;
let mut items = Vec::with_capacity(rows.len());
for row in &rows {
let mut item = row_to_list_item(row);
item.tags = self.tags_for_meeting(&item.id).await?;
items.push(item);
}
Ok(items)
}
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError> {
let row = sqlx::query(
"SELECT id, title, started_at, ended_at, duration_secs, status, recorded, language, backend_used, model_used, calendar_event_id, template_id, audio_layout
FROM meetings WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(id.clone()))?;
let speaker_rows = sqlx::query(
"SELECT label, display_name, participant_id, merged_into FROM speakers WHERE meeting_id = ? ORDER BY label",
)
.bind(id)
.fetch_all(&self.pool)
.await?;
// T4.5: rows with merged_into set are folded away — only canonical
// speakers are returned, but their raw label still resolves through
// `merge_map` below so folded segments render under the right one.
let merge_map: HashMap<String, String> = speaker_rows
.iter()
.filter_map(|r| {
let merged_into: Option<String> = r.get("merged_into");
merged_into.map(|into| (r.get::<String, _>("label"), into))
})
.collect();
let speakers: Vec<SpeakerInfo> = speaker_rows
.iter()
.filter(|r| r.get::<Option<String>, _>("merged_into").is_none())
.map(|r| SpeakerInfo {
label: r.get("label"),
display_name: r.get("display_name"),
participant_id: r.get("participant_id"),
})
.collect();
let folder = paths::meeting_dir(id);
let mut segments = read_artifact(&folder.join("transcript.json"))
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
.map(|t| t.segments)
.unwrap_or_default();
if !merge_map.is_empty() {
// Resolution only touches this in-memory copy — transcript.json
// on disk keeps its raw labels, regenerable and non-destructive
// (FR-SPK-5), same as display names.
for seg in &mut segments {
seg.speaker = resolve_canonical(&seg.speaker, &merge_map).to_string();
}
}
let notes_markdown = read_artifact(&folder.join("notes.md")).unwrap_or_default();
let summary = read_artifact(&folder.join("summary.json"))
.and_then(|s| serde_json::from_str::<SummaryFile>(&s).ok());
let tags = self.tags_for_meeting(id).await?;
// Table is the source of truth for confirmed/edited items; fall back
// to the summary's parsed drafts only when nothing's been saved yet.
let mut action_items = self.list_action_items(id).await?;
if action_items.is_empty() {
if let Some(s) = &summary {
action_items = s.action_items.clone();
}
}
Ok(Meeting {
id: row.get("id"),
title: row.get("title"),
started_at: row.get("started_at"),
ended_at: row.get("ended_at"),
duration_secs: row.get("duration_secs"),
status: MeetingStatus::parse(row.get::<String, _>("status").as_str()),
recorded: row.get::<i64, _>("recorded") != 0,
language: row.get("language"),
backend_used: row.get("backend_used"),
model_used: row.get("model_used"),
audio_layout: row.get("audio_layout"),
segments,
speakers,
notes_markdown,
summary,
calendar_event_id: row.get("calendar_event_id"),
tags,
template_id: row.get("template_id"),
action_items,
})
}
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError> {
// meeting_fts is a virtual table — no FK/CASCADE support, so its row
// would otherwise outlive the meeting and show up as a ghost result.
sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?")
.bind(id)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM meetings WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
let folder = paths::meeting_dir(id);
if folder.exists() {
std::fs::remove_dir_all(folder)?;
}
Ok(())
}
async fn set_meeting_status(&self, id: &MeetingId, status: &str) -> Result<(), StoreError> {
sqlx::query("UPDATE meetings SET status = ?, updated_at = ? WHERE id = ?")
.bind(status)
.bind(now_unix())
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> {
write_artifact(
&paths::meeting_dir(id).join("notes.md"),
markdown.as_bytes(),
)?;
sqlx::query("UPDATE meetings SET updated_at = ? WHERE id = ?")
.bind(now_unix())
.bind(id)
.execute(&self.pool)
.await?;
self.reindex_fts(id).await?;
Ok(())
}
/// `meeting_fts` is a plain (non-`content=`) FTS5 table, so nothing keeps
/// it in sync automatically — every write path that changes title,
/// transcript, notes, summary, or tags calls this afterward so search
/// (FR-SEARCH-1) can't drift from what's actually stored. Deletes-then-
/// inserts rather than `INSERT OR REPLACE`: FTS5 has no unique
/// constraint to conflict on.
async fn reindex_fts(&self, id: &MeetingId) -> Result<(), StoreError> {
let title: String = sqlx::query_scalar("SELECT title FROM meetings WHERE id = ?")
.bind(id)
.fetch_one(&self.pool)
.await?;
let transcript_text = read_artifact(&paths::meeting_dir(id).join("transcript.json"))
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
.map(|t| {
t.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let notes_text =
read_artifact(&paths::meeting_dir(id).join("notes.md")).unwrap_or_default();
// `read_artifact` already unseals (T8.8's vault, passthrough when
// locked/plaintext), same as the transcript/notes reads above.
let summary_text = read_artifact(&paths::meeting_dir(id).join("summary.json"))
.and_then(|s| serde_json::from_str::<SummaryFile>(&s).ok())
.map(|s| {
let mut text = s.summary_md;
for decision in &s.decisions {
text.push(' ');
text.push_str(decision);
}
for item in &s.action_items {
text.push(' ');
text.push_str(&item.text);
}
text
})
.unwrap_or_default();
let tags_text = self.tags_for_meeting(id).await?.join(" ");
sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?")
.bind(id)
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT INTO meeting_fts (meeting_id, title, transcript_text, notes_text, summary_text, tags_text)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(&title)
.bind(&transcript_text)
.bind(&notes_text)
.bind(&summary_text)
.bind(&tags_text)
.execute(&self.pool)
.await?;
Ok(())
}
async fn rename_speaker(
&self,
id: &MeetingId,
label: &str,
name: &str,
) -> Result<(), StoreError> {
self.upsert_speaker(id, label, Some(name), None).await
}
async fn list_calendar_events(
&self,
from: Option<i64>,
to: Option<i64>,
) -> Result<Vec<CalendarEvent>, StoreError> {
let rows = match (from, to) {
(Some(f), Some(t)) => {
sqlx::query(
"SELECT * FROM calendar_events WHERE starts_at >= ? AND starts_at <= ? ORDER BY starts_at",
)
.bind(f)
.bind(t)
.fetch_all(&self.pool)
.await?
}
(Some(f), None) => {
sqlx::query("SELECT * FROM calendar_events WHERE starts_at >= ? ORDER BY starts_at")
.bind(f)
.fetch_all(&self.pool)
.await?
}
(None, Some(t)) => {
sqlx::query("SELECT * FROM calendar_events WHERE starts_at <= ? ORDER BY starts_at")
.bind(t)
.fetch_all(&self.pool)
.await?
}
(None, None) => {
sqlx::query("SELECT * FROM calendar_events ORDER BY starts_at")
.fetch_all(&self.pool)
.await?
}
};
Ok(rows.iter().map(row_to_calendar_event).collect())
}
async fn get_calendar_event(&self, id: &str) -> Result<CalendarEventDetail, StoreError> {
let row = sqlx::query("SELECT * FROM calendar_events WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(id.to_string()))?;
let event = row_to_calendar_event(&row);
let participant_rows = sqlx::query(
"SELECT p.id, p.name, p.email, cep.role FROM calendar_event_participants cep
JOIN participants p ON p.id = cep.participant_id
WHERE cep.calendar_event_id = ? ORDER BY p.name",
)
.bind(id)
.fetch_all(&self.pool)
.await?;
let participants = participant_rows
.iter()
.map(|r| Participant {
id: r.get("id"),
name: r.get("name"),
email: r.get("email"),
role: r.get("role"),
})
.collect();
Ok(CalendarEventDetail {
event,
participants,
})
}
async fn cleanup_calendar_events(
&self,
older_than_unix: Option<i64>,
) -> Result<(u32, u32), StoreError> {
// Protected = referenced by any meeting's calendar_event_id, full
// stop — never deleted regardless of age or the "delete all" choice.
let protected: i64 = match older_than_unix {
Some(cutoff) => {
sqlx::query_scalar(
"SELECT COUNT(*) FROM calendar_events
WHERE starts_at < ?
AND id IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)",
)
.bind(cutoff)
.fetch_one(&self.pool)
.await?
}
None => {
sqlx::query_scalar(
"SELECT COUNT(*) FROM calendar_events
WHERE id IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)",
)
.fetch_one(&self.pool)
.await?
}
};
// calendar_event_participants cascades via its ON DELETE CASCADE FK.
let deleted = match older_than_unix {
Some(cutoff) => {
sqlx::query(
"DELETE FROM calendar_events
WHERE starts_at < ?
AND id NOT IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)",
)
.bind(cutoff)
.execute(&self.pool)
.await?
.rows_affected()
}
None => {
sqlx::query(
"DELETE FROM calendar_events
WHERE id NOT IN (SELECT calendar_event_id FROM meetings WHERE calendar_event_id IS NOT NULL)",
)
.execute(&self.pool)
.await?
.rows_affected()
}
};
Ok((deleted as u32, protected as u32))
}
async fn attach_meeting_to_event(
&self,
meeting_id: &MeetingId,
event_id: &str,
) -> Result<(), StoreError> {
let row: Option<(String, Option<String>)> =
sqlx::query_as("SELECT id, subject FROM calendar_events WHERE id = ?")
.bind(event_id)
.fetch_optional(&self.pool)
.await?;
let Some((_, subject)) = row else {
return Err(StoreError::NotFound(format!("calendar event {event_id}")));
};
let result = match subject.filter(|s| !s.is_empty()) {
Some(title) => sqlx::query(
"UPDATE meetings SET calendar_event_id = ?, title = ?, updated_at = ? WHERE id = ?",
)
.bind(event_id)
.bind(title)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?,
None => {
sqlx::query(
"UPDATE meetings SET calendar_event_id = ?, updated_at = ? WHERE id = ?",
)
.bind(event_id)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?
}
};
if result.rows_affected() == 0 {
return Err(StoreError::NotFound(meeting_id.clone()));
}
Ok(())
}
async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError> {
let result = sqlx::query("UPDATE meetings SET title = ?, updated_at = ? WHERE id = ?")
.bind(title)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(StoreError::NotFound(meeting_id.clone()));
}
Ok(())
}
async fn set_meeting_times(
&self,
meeting_id: &MeetingId,
started_at: i64,
ended_at: Option<i64>,
) -> Result<(), StoreError> {
sqlx::query(
"UPDATE meetings SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?",
)
.bind(started_at)
.bind(ended_at)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn map_speaker_to_participant(
&self,
meeting_id: &MeetingId,
label: &str,
participant_id: &str,
) -> Result<(), StoreError> {
let name: String = sqlx::query_scalar("SELECT name FROM participants WHERE id = ?")
.bind(participant_id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(format!("participant {participant_id}")))?;
self.upsert_speaker(meeting_id, label, Some(&name), Some(participant_id))
.await
}
async fn clear_speakers(&self, id: &MeetingId) -> Result<(), StoreError> {
sqlx::query("DELETE FROM speakers WHERE meeting_id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn merge_speakers(
&self,
id: &MeetingId,
from: &[String],
into: &str,
) -> Result<(), StoreError> {
// Ensure the canonical label has a row, without clobbering a name it
// may already have (plain upsert_speaker would overwrite display_name
// with `None` if `into` hasn't been named yet).
sqlx::query(
"INSERT INTO speakers (id, meeting_id, label) VALUES (?, ?, ?)
ON CONFLICT(meeting_id, label) DO NOTHING",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(id)
.bind(into)
.execute(&self.pool)
.await?;
for label in from {
if label == into {
continue; // merging a label into itself is a no-op
}
sqlx::query(
"INSERT INTO speakers (id, meeting_id, label, merged_into) VALUES (?, ?, ?, ?)
ON CONFLICT(meeting_id, label) DO UPDATE SET merged_into = excluded.merged_into",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(id)
.bind(label)
.bind(into)
.execute(&self.pool)
.await?;
}
Ok(())
}
async fn save_action_items(
&self,
id: &MeetingId,
items: &[ActionItem],
) -> Result<Vec<ActionItem>, StoreError> {
let now = now_unix();
// Reconcile deletes: any row previously saved for this meeting that the
// caller no longer includes was removed in the UI (FR-LLM-3). Action
// items per meeting number in the low tens, so a per-row delete loop is
// fine. ponytail: O(n) delete scan, batch it only if n ever gets large.
let keep: std::collections::HashSet<&str> =
items.iter().filter_map(|i| i.id.as_deref()).collect();
let existing_ids: Vec<String> =
sqlx::query_scalar("SELECT id FROM action_items WHERE meeting_id = ?")
.bind(id)
.fetch_all(&self.pool)
.await?;
for eid in &existing_ids {
if !keep.contains(eid.as_str()) {
sqlx::query("DELETE FROM action_items WHERE id = ? AND meeting_id = ?")
.bind(eid)
.bind(id)
.execute(&self.pool)
.await?;
}
}
let mut saved = Vec::with_capacity(items.len());
for item in items {
let item_id = match &item.id {
Some(existing_id) => {
sqlx::query(
"UPDATE action_items SET text = ?, owner = ?, due_at = ?, confirmed = ?, reminder_set = ?
WHERE id = ? AND meeting_id = ?",
)
.bind(&item.text)
.bind(&item.owner)
.bind(item.due_at)
.bind(item.confirmed as i64)
.bind(item.reminder_set as i64)
.bind(existing_id)
.bind(id)
.execute(&self.pool)
.await?;
existing_id.clone()
}
None => {
let new_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO action_items (id, meeting_id, text, owner, due_at, confirmed, reminder_set, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&new_id)
.bind(id)
.bind(&item.text)
.bind(&item.owner)
.bind(item.due_at)
.bind(item.confirmed as i64)
.bind(item.reminder_set as i64)
.bind(now)
.execute(&self.pool)
.await?;
new_id
}
};
saved.push(ActionItem {
id: Some(item_id),
..item.clone()
});
}
Ok(saved)
}
async fn import_calendar_events(&self, events: Vec<ImportedEvent>) -> Result<u32, StoreError> {
let mut imported = 0u32;
for ImportedEvent { event, attendees } in events {
// Bug fix: `raw_uid = NULL` is explicitly exempt from the
// (source, raw_uid) unique index below, so any caller that
// passes one through duplicated that event on every re-import
// forever. `parse_vevents` no longer produces one, but default
// here too — defense in depth for any other/future import path.
let raw_uid = event.raw_uid.clone().or_else(|| {
Some(crate::calendar::content_uid(
&event.subject,
&event.organizer,
event.starts_at,
event.ends_at,
))
});
sqlx::query(
"INSERT INTO calendar_events (id, source, subject, organizer, starts_at, ends_at, description, raw_uid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, raw_uid) WHERE raw_uid IS NOT NULL DO UPDATE SET
subject = excluded.subject,
organizer = excluded.organizer,
starts_at = excluded.starts_at,
ends_at = excluded.ends_at,
description = excluded.description",
)
.bind(&event.id)
.bind(&event.source)
.bind(&event.subject)
.bind(&event.organizer)
.bind(event.starts_at)
.bind(event.ends_at)
.bind(&event.description)
.bind(&raw_uid)
.execute(&self.pool)
.await?;
// A re-import may have updated an *existing* row rather than
// inserting `event.id` — resolve the row that's actually there
// before linking attendees to it.
let row_id: String = match &raw_uid {
Some(raw_uid) => {
sqlx::query("SELECT id FROM calendar_events WHERE source = ? AND raw_uid = ?")
.bind(&event.source)
.bind(raw_uid)
.fetch_one(&self.pool)
.await?
.get("id")
}
None => event.id.clone(),
};
for attendee in &attendees {
let participant_id = self
.upsert_participant(&attendee.name, attendee.email.as_deref())
.await?;
sqlx::query(
"INSERT INTO calendar_event_participants (calendar_event_id, participant_id, role)
VALUES (?, ?, ?)
ON CONFLICT(calendar_event_id, participant_id) DO UPDATE SET role = excluded.role",
)
.bind(&row_id)
.bind(&participant_id)
.bind(&attendee.role)
.execute(&self.pool)
.await?;
}
imported += 1;
}
Ok(imported)
}
async fn search(&self, query: &str) -> Result<Vec<SearchHit>, StoreError> {
if query.trim().is_empty() {
return Ok(Vec::new());
}
let fts_query = fts_query_from_input(query);
let rows = sqlx::query(
"SELECT m.id, m.title, m.started_at, m.duration_secs, m.status,
snippet(meeting_fts, -1, '', '', '…', 12) AS snippet
FROM meeting_fts
JOIN meetings m ON m.id = meeting_fts.meeting_id
WHERE meeting_fts MATCH ?
ORDER BY meeting_fts.rank
LIMIT 50",
)
.bind(&fts_query)
.fetch_all(&self.pool)
.await?;
let mut hits = Vec::with_capacity(rows.len());
for row in &rows {
let item = row_to_list_item(row);
let tags = self.tags_for_meeting(&item.id).await?;
hits.push(SearchHit {
id: item.id,
title: item.title,
started_at: item.started_at,
duration_secs: item.duration_secs,
status: item.status,
tags,
snippet: row.get("snippet"),
});
}
Ok(hits)
}
async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError> {
sqlx::query("DELETE FROM meeting_tags WHERE meeting_id = ?")
.bind(meeting_id)
.execute(&self.pool)
.await?;
for name in tags {
let name = name.trim();
if name.is_empty() {
continue;
}
let tag_id = self.upsert_tag(name).await?;
sqlx::query("INSERT OR IGNORE INTO meeting_tags (meeting_id, tag_id) VALUES (?, ?)")
.bind(meeting_id)
.bind(&tag_id)
.execute(&self.pool)
.await?;
}
self.reindex_fts(meeting_id).await?;
Ok(())
}
async fn list_tags(&self) -> Result<Vec<String>, StoreError> {
Ok(sqlx::query_scalar("SELECT name FROM tags ORDER BY name")
.fetch_all(&self.pool)
.await?)
}
async fn list_pending_reminders(&self) -> Result<Vec<ActionItem>, StoreError> {
let rows = sqlx::query(
"SELECT id, text, owner, due_at, confirmed, reminder_set FROM action_items
WHERE reminder_set = 1 AND due_at IS NOT NULL AND due_at > ?",
)
.bind(now_unix())
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|r| ActionItem {
id: Some(r.get("id")),
text: r.get("text"),
owner: r.get("owner"),
due_at: r.get("due_at"),
confirmed: r.get::<i64, _>("confirmed") != 0,
reminder_set: r.get::<i64, _>("reminder_set") != 0,
})
.collect())
}
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {
let rows = sqlx::query(
"SELECT id, audio_path FROM meetings WHERE status IN ('recording', 'transcribing')",
)
.fetch_all(&self.pool)
.await?;
let mut recovered = Vec::new();
for row in rows {
let id: MeetingId = row.get("id");
let audio_path: String = row.get("audio_path");
if PathBuf::from(&audio_path).exists() {
sqlx::query(
"UPDATE meetings SET status = 'recovering', updated_at = ? WHERE id = ?",
)
.bind(now_unix())
.bind(&id)
.execute(&self.pool)
.await?;
recovered.push(id);
} else {
// No working audio and never finalized: nothing to recover from.
sqlx::query("UPDATE meetings SET status = 'error', updated_at = ? WHERE id = ?")
.bind(now_unix())
.bind(&id)
.execute(&self.pool)
.await?;
}
}
Ok(recovered)
}
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError> {
if policy.max_age_days.is_none() && policy.max_size_gb.is_none() {
return Ok(0);
}
let candidates = sqlx::query(
"SELECT id, folder_path, started_at FROM meetings WHERE status IN ('ready', 'error') ORDER BY started_at ASC",
)
.fetch_all(&self.pool)
.await?;
let mut removed = 0u32;
let now = now_unix();
let max_age_secs = policy.max_age_days.map(|d| d as i64 * 86_400);
// Age-based pruning first (cheap, no disk walk needed).
let mut kept = Vec::new();
for row in &candidates {
let id: MeetingId = row.get("id");
let started_at: i64 = row.get("started_at");
if let Some(max_age) = max_age_secs {
if now - started_at > max_age {
self.delete_meeting(&id).await?;
removed += 1;
continue;
}
}
kept.push((id, row.get::<String, _>("folder_path")));
}
// Size-based pruning, oldest first, until under the cap.
if let Some(max_gb) = policy.max_size_gb {
let cap_bytes = max_gb as u64 * 1_073_741_824;
let sizes: Vec<(MeetingId, u64)> = kept
.iter()
.map(|(id, folder)| (id.clone(), dir_size(folder)))
.collect();
let mut total: u64 = sizes.iter().map(|(_, s)| s).sum();
let mut idx = 0;
while total > cap_bytes && idx < sizes.len() {
let (id, size) = &sizes[idx];
self.delete_meeting(id).await?;
total = total.saturating_sub(*size);
removed += 1;
idx += 1;
}
}
Ok(removed)
}
async fn add_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError> {
sqlx::query(
"INSERT INTO sync_targets (id, name, kind, provider_hint, base_url, remote_base_path, \
username, credential_ref, enabled, upload_transcript, upload_notes, upload_summary, \
upload_recording, trigger_on_finalize, allow_plaintext_lan, encrypt_before_upload, created_at) \
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(&row.id)
.bind(&row.name)
.bind(&row.kind)
.bind(&row.provider_hint)
.bind(&row.base_url)
.bind(&row.remote_base_path)
.bind(&row.username)
.bind(&row.credential_ref)
.bind(row.enabled)
.bind(row.upload_transcript)
.bind(row.upload_notes)
.bind(row.upload_summary)
.bind(row.upload_recording)
.bind(row.trigger_on_finalize)
.bind(row.allow_plaintext_lan)
.bind(row.encrypt_before_upload)
.bind(row.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError> {
sqlx::query(
"UPDATE sync_targets SET name=?, kind=?, provider_hint=?, base_url=?, remote_base_path=?, \
username=?, credential_ref=?, enabled=?, upload_transcript=?, upload_notes=?, \
upload_summary=?, upload_recording=?, trigger_on_finalize=?, allow_plaintext_lan=?, \
encrypt_before_upload=? WHERE id=?",
)
.bind(&row.name)
.bind(&row.kind)
.bind(&row.provider_hint)
.bind(&row.base_url)
.bind(&row.remote_base_path)
.bind(&row.username)
.bind(&row.credential_ref)
.bind(row.enabled)
.bind(row.upload_transcript)
.bind(row.upload_notes)
.bind(row.upload_summary)
.bind(row.upload_recording)
.bind(row.trigger_on_finalize)
.bind(row.allow_plaintext_lan)
.bind(row.encrypt_before_upload)
.bind(&row.id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_sync_target(&self, id: &str) -> Result<(), StoreError> {
// ON DELETE CASCADE clears this target's sync_jobs too.
sqlx::query("DELETE FROM sync_targets WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_sync_targets(&self) -> Result<Vec<SyncTargetRow>, StoreError> {
let rows = sqlx::query_as::<_, SyncTargetRow>(
"SELECT * FROM sync_targets ORDER BY created_at ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn get_sync_target(&self, id: &str) -> Result<SyncTargetRow, StoreError> {
let row = sqlx::query_as::<_, SyncTargetRow>("SELECT * FROM sync_targets WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(format!("sync target {id}")))?;
Ok(row)
}
async fn upsert_sync_job(&self, job: SyncJobRow) -> Result<bool, StoreError> {
// ON CONFLICT: re-enqueue as pending, EXCEPT when the row is already
// `done` with the same content hash — that's the skip-if-unchanged case
// (`IS NOT` is SQLite's null-safe comparison).
let res = sqlx::query(
"INSERT INTO sync_jobs (id, target_id, meeting_id, artifact, local_path, remote_path, \
sha256, status, attempts, last_error, next_attempt_at, bytes_total, bytes_sent, updated_at) \
VALUES (?,?,?,?,?,?,?, 'pending', 0, NULL, NULL, ?, 0, ?) \
ON CONFLICT(target_id, meeting_id, artifact) DO UPDATE SET \
local_path=excluded.local_path, remote_path=excluded.remote_path, sha256=excluded.sha256, \
status='pending', attempts=0, last_error=NULL, next_attempt_at=NULL, \
bytes_total=excluded.bytes_total, bytes_sent=0, updated_at=excluded.updated_at \
WHERE sync_jobs.status != 'done' OR sync_jobs.sha256 IS NOT excluded.sha256",
)
.bind(&job.id)
.bind(&job.target_id)
.bind(&job.meeting_id)
.bind(&job.artifact)
.bind(&job.local_path)
.bind(&job.remote_path)
.bind(&job.sha256)
.bind(job.bytes_total)
.bind(job.updated_at)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn claim_due_sync_jobs(&self, now: i64) -> Result<Vec<SyncJobRow>, StoreError> {
let rows = sqlx::query_as::<_, SyncJobRow>(
"SELECT * FROM sync_jobs WHERE status IN ('pending','failed') \
AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY updated_at ASC",
)
.bind(now)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn update_sync_job(&self, job: SyncJobRow) -> Result<(), StoreError> {
sqlx::query(
"UPDATE sync_jobs SET status=?, attempts=?, last_error=?, next_attempt_at=?, \
bytes_total=?, bytes_sent=?, sha256=?, local_path=?, remote_path=?, updated_at=? WHERE id=?",
)
.bind(&job.status)
.bind(job.attempts)
.bind(&job.last_error)
.bind(job.next_attempt_at)
.bind(job.bytes_total)
.bind(job.bytes_sent)
.bind(&job.sha256)
.bind(&job.local_path)
.bind(&job.remote_path)
.bind(job.updated_at)
.bind(&job.id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_sync_job(&self, id: &str) -> Result<SyncJobRow, StoreError> {
sqlx::query_as::<_, SyncJobRow>("SELECT * FROM sync_jobs WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(format!("sync job {id}")))
}
async fn list_sync_jobs(
&self,
meeting_id: Option<&MeetingId>,
) -> Result<Vec<SyncJobRow>, StoreError> {
let rows = match meeting_id {
Some(m) => {
sqlx::query_as::<_, SyncJobRow>(
"SELECT * FROM sync_jobs WHERE meeting_id = ? ORDER BY updated_at DESC",
)
.bind(m)
.fetch_all(&self.pool)
.await?
}
None => {
sqlx::query_as::<_, SyncJobRow>("SELECT * FROM sync_jobs ORDER BY updated_at DESC")
.fetch_all(&self.pool)
.await?
}
};
Ok(rows)
}
async fn insert_feature_brief(&self, row: FeatureBriefRow) -> Result<(), StoreError> {
sqlx::query(
"INSERT INTO feature_briefs (id, meeting_id, title, target_repo, path, exposed, created_at) \
VALUES (?,?,?,?,?,?,?)",
)
.bind(&row.id)
.bind(&row.meeting_id)
.bind(&row.title)
.bind(&row.target_repo)
.bind(&row.path)
.bind(row.exposed)
.bind(row.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_feature_briefs(
&self,
meeting_id: Option<&MeetingId>,
) -> Result<Vec<FeatureBriefInfo>, StoreError> {
let rows =
match meeting_id {
Some(m) => sqlx::query_as::<_, FeatureBriefRow>(
"SELECT * FROM feature_briefs WHERE meeting_id = ? ORDER BY created_at DESC",
)
.bind(m)
.fetch_all(&self.pool)
.await?,
None => {
sqlx::query_as::<_, FeatureBriefRow>(
"SELECT * FROM feature_briefs ORDER BY created_at DESC",
)
.fetch_all(&self.pool)
.await?
}
};
Ok(rows.into_iter().map(FeatureBriefInfo::from).collect())
}
async fn get_feature_brief_row(&self, id: &str) -> Result<FeatureBriefRow, StoreError> {
sqlx::query_as::<_, FeatureBriefRow>("SELECT * FROM feature_briefs WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(format!("feature brief {id}")))
}
async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError> {
let res = sqlx::query("UPDATE feature_briefs SET exposed = ? WHERE id = ?")
.bind(exposed)
.bind(id)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(StoreError::NotFound(format!("feature brief {id}")));
}
Ok(())
}
async fn list_action_items(
&self,
meeting_id: &MeetingId,
) -> Result<Vec<ActionItem>, StoreError> {
let rows = sqlx::query(
"SELECT id, text, owner, due_at, confirmed, reminder_set FROM action_items \
WHERE meeting_id = ? ORDER BY created_at ASC",
)
.bind(meeting_id)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|r| ActionItem {
id: Some(r.get("id")),
text: r.get("text"),
owner: r.get("owner"),
due_at: r.get("due_at"),
confirmed: r.get::<i64, _>("confirmed") != 0,
reminder_set: r.get::<i64, _>("reminder_set") != 0,
})
.collect())
}
async fn record_mcp_access(
&self,
tool: &str,
meeting_id: Option<&MeetingId>,
client: Option<&str>,
) -> Result<(), StoreError> {
sqlx::query(
"INSERT INTO mcp_access_log (id, at, tool, meeting_id, client) VALUES (?,?,?,?,?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(now_unix())
.bind(tool)
.bind(meeting_id)
.bind(client)
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_mcp_access_log(
&self,
limit: Option<u32>,
) -> Result<Vec<McpAccessEntry>, StoreError> {
let cap: i64 = limit.map(i64::from).unwrap_or(200).max(1);
let rows = sqlx::query(
"SELECT at, tool, meeting_id, client FROM mcp_access_log ORDER BY at DESC LIMIT ?",
)
.bind(cap)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|r| McpAccessEntry {
at: r.get("at"),
tool: r.get("tool"),
meeting_id: r.get("meeting_id"),
client: r.get("client"),
})
.collect())
}
}
/// Read a derived artifact, transparently decrypting it if the vault sealed it
/// (T8.8, FR-SEC-3). Returns `None` if the file is missing or can't be
/// decrypted (vault locked / wrong key) — callers already treat a missing
/// artifact as empty, so a locked vault degrades to "no content" rather than a
/// crash.
fn read_artifact(path: &std::path::Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
let plain = crate::vault::open(&bytes).ok()?;
Some(String::from_utf8_lossy(&plain).into_owned())
}
/// Write a derived artifact, sealing it when the vault is unlocked (a no-op
/// passthrough otherwise). Audio is not sealed here — it's handled separately.
fn write_artifact(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
let sealed = crate::vault::seal(contents).map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::write(path, sealed)
}
fn dir_size(path: &str) -> u64 {
std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| entry.metadata().ok())
.map(|m| m.len())
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_target(id: &str) -> SyncTargetRow {
SyncTargetRow {
id: id.to_string(),
name: "t".to_string(),
kind: "webdav".to_string(),
provider_hint: None,
base_url: Some("https://host/dav".to_string()),
remote_base_path: "/WhispAssist".to_string(),
username: Some("u".to_string()),
credential_ref: format!("wa-sync-{id}"),
enabled: true,
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
created_at: 0,
}
}
fn sample_job(id: &str, sha: &str) -> SyncJobRow {
SyncJobRow {
id: id.to_string(),
target_id: "t1".to_string(),
meeting_id: "m1".to_string(),
artifact: "transcript".to_string(),
local_path: "/x/transcript.json".to_string(),
remote_path: "/WhispAssist/m1/transcript.json".to_string(),
sha256: Some(sha.to_string()),
status: "pending".to_string(),
attempts: 0,
last_error: None,
next_attempt_at: None,
bytes_total: Some(10),
bytes_sent: 0,
updated_at: 0,
}
}
/// T8.7/FR-TRX-4: the language requested at `create_meeting` is visible
/// immediately (not just after `finalize_meeting`) — a crash-recovered
/// `recovering` meeting still knows what was asked for.
#[tokio::test]
async fn create_meeting_persists_the_requested_language_immediately() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let id = store
.create_meeting(NewMeeting {
title: "Reunión semanal".to_string(),
calendar_event_id: None,
template_id: None,
language: Some("es".to_string()),
})
.await
.unwrap();
let meeting = store.get_meeting(&id).await.unwrap();
assert_eq!(meeting.language.as_deref(), Some("es"));
}
/// `finalize_meeting` overwrites whatever `create_meeting` stored with
/// the language whisper.cpp actually resolved/detected (T8.7, FR-TRX-4)
/// — e.g. "auto" mode's detected result, or an English-only model's
/// forced "en".
#[tokio::test]
async fn finalize_meeting_overwrites_the_requested_language_with_the_resolved_one() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let id = store
.create_meeting(NewMeeting {
title: "Auto-detect meeting".to_string(),
calendar_event_id: None,
template_id: None,
language: None, // requested "auto"
})
.await
.unwrap();
store
.finalize_meeting(
&id,
FinalizeMeeting {
segments: Vec::new(),
speakers: Vec::new(),
duration_secs: 42,
recorded: false,
language: Some("fr".to_string()), // what auto-detect resolved to
backend_used: Some("cpu".to_string()),
model_used: Some("small-q5_1".to_string()),
audio_layout: None,
},
)
.await
.unwrap();
let meeting = store.get_meeting(&id).await.unwrap();
assert_eq!(meeting.language.as_deref(), Some("fr"));
}
/// End-to-end regression for the search bug: `meeting_fts` originally had
/// no summary/tags columns at all and nothing reindexed on those writes,
/// so search silently missed anything that wasn't in the title,
/// transcript, or notes (FR-SEARCH-1).
#[tokio::test]
async fn search_finds_hits_via_transcript_notes_summary_and_tags() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let id = store
.create_meeting(NewMeeting {
title: "Weekly sync".to_string(),
calendar_event_id: None,
template_id: None,
language: None,
})
.await
.unwrap();
store
.finalize_meeting(
&id,
FinalizeMeeting {
segments: vec![TranscriptSegment {
id: 0,
start_ms: 0,
end_ms: 1000,
speaker: "S1".to_string(),
text: "let's discuss the transcriptword rollout".to_string(),
confidence: None,
interim: false,
}],
speakers: Vec::new(),
duration_secs: 60,
recorded: false,
language: Some("en".to_string()),
backend_used: Some("cpu".to_string()),
model_used: Some("small-q5_1".to_string()),
audio_layout: None,
},
)
.await
.unwrap();
store
.update_notes(&id, "action: follow up on notesword")
.await
.unwrap();
store.set_tags(&id, &["tagword".to_string()]).await.unwrap();
// summary.json isn't written through the store (generate_summary
// seals it straight to disk in commands.rs), so reindex_fts must
// pick it up when explicitly told to, same as that command does.
let summary = SummaryFile {
schema: 1,
generated_at: 0,
provider: "test".to_string(),
model: "test".to_string(),
summary_md: "summaryword recap".to_string(),
decisions: Vec::new(),
action_items: Vec::new(),
};
write_artifact(
&paths::meeting_dir(&id).join("summary.json"),
serde_json::to_string(&summary).unwrap().as_bytes(),
)
.unwrap();
store.reindex_fts(&id).await.unwrap();
for (query, source) in [
("transcriptword", "transcript"),
("notesword", "notes"),
("summaryword", "summary"),
("tagword", "tags"),
] {
let hits = store.search(query).await.unwrap();
assert!(
hits.iter().any(|h| h.id == id),
"expected a hit from {source} for query {query:?}, got {hits:?}"
);
}
}
#[tokio::test]
async fn rename_meeting_updates_the_title() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
language: None,
})
.await
.unwrap();
store.rename_meeting(&id, "Sprint planning").await.unwrap();
assert_eq!(
store.get_meeting(&id).await.unwrap().title,
"Sprint planning"
);
}
#[tokio::test]
async fn rename_meeting_errs_for_an_unknown_id() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let result = store.rename_meeting(&"no-such-id".to_string(), "x").await;
assert!(matches!(result, Err(StoreError::NotFound(_))));
}
#[tokio::test]
async fn attach_meeting_to_event_mirrors_the_events_subject_onto_the_title() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let meeting_id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
language: None,
})
.await
.unwrap();
store
.import_calendar_events(vec![ImportedEvent {
event: CalendarEvent {
id: "ev1".to_string(),
source: "pst".to_string(),
subject: Some("Jerry / Daniel - Weekly 1:1".to_string()),
organizer: None,
starts_at: None,
ends_at: None,
description: None,
raw_uid: Some("uid-1".to_string()),
},
attendees: vec![],
}])
.await
.unwrap();
let event_id = store.list_calendar_events(None, None).await.unwrap()[0]
.id
.clone();
store
.attach_meeting_to_event(&meeting_id, &event_id)
.await
.unwrap();
let meeting = store.get_meeting(&meeting_id).await.unwrap();
assert_eq!(meeting.title, "Jerry / Daniel - Weekly 1:1");
assert_eq!(
meeting.calendar_event_id.as_deref(),
Some(event_id.as_str())
);
}
#[tokio::test]
async fn attach_meeting_to_event_leaves_the_title_alone_when_the_event_has_no_subject() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let meeting_id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
language: None,
})
.await
.unwrap();
store
.import_calendar_events(vec![ImportedEvent {
event: CalendarEvent {
id: "ev1".to_string(),
source: "pst".to_string(),
subject: None,
organizer: None,
starts_at: None,
ends_at: None,
description: None,
raw_uid: Some("uid-2".to_string()),
},
attendees: vec![],
}])
.await
.unwrap();
let event_id = store.list_calendar_events(None, None).await.unwrap()[0]
.id
.clone();
store
.attach_meeting_to_event(&meeting_id, &event_id)
.await
.unwrap();
assert_eq!(
store.get_meeting(&meeting_id).await.unwrap().title,
"Untitled meeting"
);
}
/// Regression for the calendar duplicate-import bug: re-importing the
/// exact same events (same `(source, raw_uid)`) must update the existing
/// rows in place, not add new ones -- whether or not the source
/// supplied a `raw_uid` at all.
#[tokio::test]
async fn reimporting_the_same_events_does_not_duplicate_rows() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let events = || {
vec![
ImportedEvent {
event: CalendarEvent {
id: "ev1".to_string(),
source: "pst".to_string(),
subject: Some("Weekly 1:1".to_string()),
organizer: None,
starts_at: Some(1_000),
ends_at: Some(2_000),
description: None,
raw_uid: Some("uid-1".to_string()),
},
attendees: vec![],
},
// No raw_uid at all -- the case that used to duplicate on
// every re-import (exempt from the unique index).
ImportedEvent {
event: CalendarEvent {
id: "ev2".to_string(),
source: "pst".to_string(),
subject: Some("No UID event".to_string()),
organizer: None,
starts_at: Some(3_000),
ends_at: Some(4_000),
description: None,
raw_uid: None,
},
attendees: vec![],
},
]
};
store.import_calendar_events(events()).await.unwrap();
assert_eq!(
store.list_calendar_events(None, None).await.unwrap().len(),
2
);
// Re-import the identical batch a few times, as a user retrying an
// import would.
for _ in 0..3 {
store.import_calendar_events(events()).await.unwrap();
}
assert_eq!(
store.list_calendar_events(None, None).await.unwrap().len(),
2
);
}
/// Regression for the "14,686 calendar events" bloat report: cleanup
/// must delete unlinked old events while never touching one attached to
/// a recorded meeting, whether age-bounded or "delete all".
#[tokio::test]
async fn cleanup_calendar_events_protects_meeting_linked_rows() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let event = |id: &str, starts_at: i64| ImportedEvent {
event: CalendarEvent {
id: id.to_string(),
source: "pst".to_string(),
subject: Some(id.to_string()),
organizer: None,
starts_at: Some(starts_at),
ends_at: Some(starts_at + 1_800),
description: None,
raw_uid: Some(id.to_string()),
},
attendees: vec![],
};
store
.import_calendar_events(vec![
event("old-unlinked", 1_000),
event("old-linked", 1_000),
event("recent-unlinked", 1_000_000_000),
])
.await
.unwrap();
let meeting_id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
language: None,
})
.await
.unwrap();
store
.attach_meeting_to_event(&meeting_id, "old-linked")
.await
.unwrap();
// Cutoff between the two "old" timestamps and the "recent" one.
let (deleted, protected) = store.cleanup_calendar_events(Some(500_000)).await.unwrap();
assert_eq!(deleted, 1, "only old-unlinked should go");
assert_eq!(protected, 1, "old-linked is attached to a meeting");
let remaining: Vec<String> = store
.list_calendar_events(None, None)
.await
.unwrap()
.into_iter()
.map(|e| e.id)
.collect();
assert!(!remaining.contains(&"old-unlinked".to_string()));
assert!(remaining.contains(&"old-linked".to_string()));
assert!(remaining.contains(&"recent-unlinked".to_string()));
}
#[tokio::test]
async fn cleanup_calendar_events_delete_all_still_protects_linked_rows() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let event = |id: &str| ImportedEvent {
event: CalendarEvent {
id: id.to_string(),
source: "pst".to_string(),
subject: Some(id.to_string()),
organizer: None,
starts_at: Some(1_000),
ends_at: Some(2_800),
description: None,
raw_uid: Some(id.to_string()),
},
attendees: vec![],
};
store
.import_calendar_events(vec![event("unlinked"), event("linked")])
.await
.unwrap();
let meeting_id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
language: None,
})
.await
.unwrap();
store
.attach_meeting_to_event(&meeting_id, "linked")
.await
.unwrap();
let (deleted, protected) = store.cleanup_calendar_events(None).await.unwrap();
assert_eq!(deleted, 1);
assert_eq!(protected, 1);
assert_eq!(
store.list_calendar_events(None, None).await.unwrap().len(),
1
);
}
#[tokio::test]
async fn sync_target_crud_round_trips() {
let store = SqliteStore::connect_in_memory().await.unwrap();
store.add_sync_target(sample_target("t1")).await.unwrap();
let listed = store.list_sync_targets().await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "t1");
store.remove_sync_target("t1").await.unwrap();
assert!(store.list_sync_targets().await.unwrap().is_empty());
}
#[tokio::test]
async fn upsert_skips_unchanged_done_job_and_reenqueues_changed() {
let store = SqliteStore::connect_in_memory().await.unwrap();
store.add_sync_target(sample_target("t1")).await.unwrap();
// New job → enqueued.
assert!(store.upsert_sync_job(sample_job("j1", "A")).await.unwrap());
// Mark it done, then re-upsert the same content (same sha) → skipped.
let mut done = store.list_sync_jobs(None).await.unwrap().remove(0);
done.status = "done".to_string();
store.update_sync_job(done).await.unwrap();
assert!(
!store.upsert_sync_job(sample_job("j2", "A")).await.unwrap(),
"an unchanged, already-done artifact must not re-enqueue"
);
// Changed content (new sha) → re-enqueued as pending, still one row.
assert!(store.upsert_sync_job(sample_job("j3", "B")).await.unwrap());
let jobs = store.list_sync_jobs(None).await.unwrap();
assert_eq!(
jobs.len(),
1,
"UNIQUE(target,meeting,artifact) keeps one row"
);
assert_eq!(jobs[0].status, "pending");
assert_eq!(jobs[0].sha256.as_deref(), Some("B"));
}
#[tokio::test]
async fn claim_due_skips_jobs_with_a_future_backoff() {
let store = SqliteStore::connect_in_memory().await.unwrap();
store.add_sync_target(sample_target("t1")).await.unwrap();
store.upsert_sync_job(sample_job("j1", "A")).await.unwrap();
// Push it into the future with a failed+backoff state.
let mut job = store.list_sync_jobs(None).await.unwrap().remove(0);
job.status = "failed".to_string();
job.next_attempt_at = Some(10_000);
store.update_sync_job(job).await.unwrap();
assert!(store.claim_due_sync_jobs(5_000).await.unwrap().is_empty());
assert_eq!(store.claim_due_sync_jobs(20_000).await.unwrap().len(), 1);
}
#[test]
fn fts_query_from_input_quotes_each_token_as_a_literal_phrase() {
assert_eq!(
fts_query_from_input("standup notes"),
"\"standup\" \"notes\""
);
}
#[test]
fn fts_query_from_input_escapes_embedded_quotes() {
assert_eq!(fts_query_from_input("say \"hi\""), "\"say\" \"\"\"hi\"\"\"");
}
#[test]
fn fts_query_from_input_neutralizes_fts5_operators() {
// Without quoting, "-" and "OR"/"AND"/"*" carry special FTS5 meaning;
// quoted, they're just literal tokens that can't blow up MATCH.
assert_eq!(
fts_query_from_input("a - b OR c*"),
"\"a\" \"-\" \"b\" \"OR\" \"c*\""
);
}
#[test]
fn fts_query_from_input_of_blank_input_is_blank() {
assert_eq!(fts_query_from_input(" "), "");
}
}