1use std::path::{Path, PathBuf};
2use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
3use std::sync::{Arc, Mutex, MutexGuard};
4use std::time::Duration;
5
6use chrono::{DateTime, SecondsFormat, Utc};
7use onlyne_proto::text::SchemaMismatch;
8use onlyne_proto::{
9 Envelope, Event, FaultEvent, LedgerQuery, LedgerState, LedgerStateEvent, Lifecycle, MsgKind,
10 Outcome, Principal, QueryFaultsArgs, QuerySessionsArgs,
11};
12use rusqlite::types::{Type, Value as SqlValue};
13use rusqlite::{Connection, OpenFlags, OptionalExtension, Row, params, params_from_iter};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use unicode_segmentation::UnicodeSegmentation;
17
18use crate::error::{StoreError, StoreResult};
19use crate::liveness::LiveSessions;
20use crate::transition_allowed;
21
22const SERVER_SCHEMA_VERSION: i64 = 6;
48const PROTOCOL_VERSION: i64 = 1;
49const SERVER_MARKER: &str = "onlyne-server";
50const DEFAULT_LIMIT: i64 = 100;
51
52pub const SERVER_DDL: &str = r#"CREATE TABLE IF NOT EXISTS roles(
53 name TEXT PRIMARY KEY,
54 key TEXT NOT NULL,
55 admin INTEGER NOT NULL,
56 max_sessions INTEGER NOT NULL,
57 spec_hash TEXT NOT NULL,
58 updated_at TEXT NOT NULL
59);
60CREATE TABLE IF NOT EXISTS sessions(
61 session_id TEXT PRIMARY KEY,
62 role TEXT NOT NULL,
63 generation INTEGER NOT NULL,
64 seq INTEGER NOT NULL,
65 agent_state TEXT NOT NULL,
66 delivery_state TEXT NOT NULL,
67 resource_state TEXT NOT NULL,
68 recovery_substate TEXT NOT NULL,
69 -- The (generation, seq) gate. The session reducer's isolate-after-N and
70 -- terminate-after-N policy needs a persisted counter, so this pair carries
71 -- DEFAULT_ISOLATE_AFTER and DEFAULT_TERMINATE_AFTER.
72 desired_json TEXT NOT NULL,
73 -- The client's published projection whole, lifecycle included: there is no
74 -- column beside it to fall back to, and every reader of the mirror parses the
75 -- lifecycle out of these bytes.
76 observed_json TEXT NOT NULL,
77 mismatch_count INTEGER NOT NULL,
78 -- The mirror's own freshness: what a reader judges a stale row by. v1's
79 -- mirror could be hours old and read as current.
80 last_seen TEXT NOT NULL,
81 -- When the projection content last moved. A beat that only refreshes
82 -- `last_seen` leaves it alone.
83 updated_at TEXT NOT NULL
84);
85-- The role-addressed reads: one role's sessions, which the note rule and the
86-- stale scan ask for.
87CREATE INDEX IF NOT EXISTS sessions_role_idx ON sessions(role);
88-- The order `list_sessions` reads in, ascending so the read walks it backwards:
89-- a descending index does not satisfy `updated_at DESC, rowid DESC` — SQLite
90-- leaves a `TEMP B-TREE FOR LAST TERM` and spills it to a temporary file on
91-- every read, and a reader that polls once a second turns that into megabytes
92-- per second of writes nothing asked for.
93CREATE INDEX IF NOT EXISTS sessions_updated_idx ON sessions(updated_at);
94-- Which delivery a session serves, and which sessions have served a delivery.
95-- The mirror row answers for a session; this table is the only place a
96-- delivery binding lives, so a session that served one delivery and then
97-- another is one row here twice rather than two mirror rows.
98CREATE TABLE IF NOT EXISTS session_tasks(
99 session_id TEXT NOT NULL,
100 task_id TEXT NOT NULL,
101 bound_at TEXT NOT NULL,
102 released_at TEXT,
103 PRIMARY KEY (session_id, task_id)
104);
105-- The reverse read: the session serving a task.
106CREATE INDEX IF NOT EXISTS session_tasks_task_idx ON session_tasks(task_id);
107-- The open binding of one session: at most one row per session is unreleased,
108-- because a session serves one delivery at a time.
109CREATE INDEX IF NOT EXISTS session_tasks_open_idx ON session_tasks(session_id, released_at);
110CREATE TABLE IF NOT EXISTS ledger(
111 msg_id TEXT PRIMARY KEY,
112 op_id TEXT UNIQUE,
113 fingerprint TEXT,
114 kind TEXT NOT NULL,
115 from_json TEXT NOT NULL,
116 to_json TEXT NOT NULL,
117 task TEXT,
118 parent_task TEXT,
119 attempt INTEGER NOT NULL,
120 state TEXT NOT NULL,
121 out_head TEXT,
122 reason TEXT,
123 enqueued_at TEXT NOT NULL,
124 acked_at TEXT,
125 -- Nullable because retention pruning clears an acknowledged body after the
126 -- cutoff.
127 body_json TEXT,
128 -- Causality's hop count from the root task. `parent_task` alone gives the
129 -- chain's shape, not its depth; `onlyne handoff` reads both back to extend
130 -- the chain, so the row stores the counter beside the link.
131 hop INTEGER NOT NULL DEFAULT 0,
132 -- Persisted expiry deadline of a ttl note, so a restarted server can re-arm
133 -- its sweep.
134 expires_at TEXT,
135 -- Times this row has moved from in_flight back to queued.
136 requeued INTEGER NOT NULL DEFAULT 0,
137 -- The family's root task id, read off the envelope's causality. `parent_task`
138 -- gives the chain's shape, and this names the arc every hop of one run
139 -- belongs to, which `onlyne ledger` prints beside the hop.
140 family TEXT,
141 -- The hops the family may spend, set by whoever started the run.
142 hop_budget INTEGER,
143 -- The role the family reports home to, carried on every row of the run.
144 origin TEXT,
145 -- Wall-clock bound for the whole family, in the RFC 3339 shape `expires_at`
146 -- uses.
147 deadline TEXT,
148 -- The causality's free-form labels as JSON text.
149 labels_json TEXT
150);
151CREATE INDEX IF NOT EXISTS ledger_state_enqueued_idx ON ledger(state,enqueued_at);
152CREATE INDEX IF NOT EXISTS ledger_task_idx ON ledger(task);
153CREATE INDEX IF NOT EXISTS ledger_kind_state_idx ON ledger(kind,state);
154-- The ledger listing's own reading order, on the same terms as the sessions
155-- index above. A listing filtered by `state` keeps using `ledger_state_enqueued_idx`
156-- and reads that backwards.
157CREATE INDEX IF NOT EXISTS ledger_enqueued_idx ON ledger(enqueued_at);
158CREATE TABLE IF NOT EXISTS events(
159 seq INTEGER PRIMARY KEY,
160 type TEXT NOT NULL,
161 data_json TEXT NOT NULL,
162 created_at TEXT NOT NULL
163);
164CREATE INDEX IF NOT EXISTS events_type_idx ON events(type);
165CREATE TABLE IF NOT EXISTS faults(
166 id INTEGER PRIMARY KEY AUTOINCREMENT,
167 task_id TEXT,
168 role TEXT,
169 session_id TEXT,
170 generation INTEGER,
171 seq INTEGER,
172 desired_json TEXT,
173 observed_json TEXT,
174 intent TEXT,
175 attempt INTEGER,
176 backend_ref TEXT,
177 kind TEXT NOT NULL,
178 reason TEXT NOT NULL,
179 state TEXT NOT NULL,
180 -- Encoded from the kernel's unix seconds through this crate's own helper on
181 -- every write.
182 created_at TEXT NOT NULL
183);
184CREATE INDEX IF NOT EXISTS faults_task_kind_generation_idx ON faults(task_id,kind,generation);
185CREATE INDEX IF NOT EXISTS faults_state_idx ON faults(state);
186-- The ghost sweep's own audit trail, one row per `working` mirror row the server
187-- settled because the task's ledger row had already reached a terminal state.
188-- `seq_before` and `seq_after` are the mirror row's two versions, so an operator
189-- reads the exact write the pass made straight off this row.
190CREATE TABLE IF NOT EXISTS ghost_sweeps(
191 id INTEGER PRIMARY KEY AUTOINCREMENT,
192 task_id TEXT NOT NULL,
193 role TEXT NOT NULL,
194 session_id TEXT NOT NULL,
195 generation INTEGER NOT NULL,
196 seq_before INTEGER NOT NULL,
197 seq_after INTEGER NOT NULL,
198 outcome TEXT NOT NULL,
199 evidence TEXT NOT NULL,
200 swept_at TEXT NOT NULL
201);
202-- The listing's own reading order, on the same terms as the sessions index
203-- above: ascending, so `swept_at DESC, rowid DESC` walks it backwards.
204CREATE INDEX IF NOT EXISTS ghost_sweeps_swept_at_idx ON ghost_sweeps(swept_at);
205CREATE TABLE IF NOT EXISTS inbox_cursors(
206 role TEXT PRIMARY KEY,
207 last_msg_id TEXT,
208 last_seq INTEGER NOT NULL,
209 updated_at TEXT NOT NULL
210);
211CREATE TABLE IF NOT EXISTS hook_cursors(
212 hook TEXT PRIMARY KEY,
213 last_seq INTEGER NOT NULL,
214 updated_at TEXT NOT NULL
215);"#;
216
217const SCHEMA_MARKER_DDL: &str = "CREATE TABLE IF NOT EXISTS schema_marker(name TEXT PRIMARY KEY, version INTEGER NOT NULL, protocol_version INTEGER NOT NULL);";
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct RoleRow {
221 pub name: String,
222 pub key: String,
223 pub admin: bool,
224 pub max_sessions: i64,
225 pub spec_hash: String,
226 pub updated_at: String,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct SessionWrite {
239 pub session_id: String,
240 pub task_id: Option<String>,
241 pub role: String,
242 pub generation: i64,
243 pub seq: i64,
244 pub agent_state: String,
245 pub delivery_state: String,
246 pub resource_state: String,
247 pub recovery_substate: String,
248 pub desired_json: String,
249 pub observed_json: String,
250 pub mismatch_count: i64,
251 pub last_seen: i64,
254 pub updated_at: i64,
256}
257
258pub type ServerSessionRow = SessionWrite;
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct SessionBindingRow {
264 pub session_id: String,
265 pub task_id: String,
266 pub bound_at: i64,
268 pub released_at: Option<i64>,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct LedgerRow {
275 pub msg_id: String,
276 pub op_id: Option<String>,
277 pub fingerprint: Option<String>,
278 pub kind: MsgKind,
279 pub from_json: String,
280 pub to_json: String,
281 pub task: Option<String>,
282 pub parent_task: Option<String>,
283 pub attempt: i64,
284 pub state: LedgerState,
285 pub out_head: Option<String>,
286 pub reason: Option<String>,
287 pub enqueued_at: String,
288 pub acked_at: Option<String>,
289 pub body_json: Option<String>,
290 pub hop: i64,
293 pub expires_at: Option<String>,
296 #[serde(default)]
298 pub requeued: i64,
299 #[serde(default)]
303 pub family: Option<String>,
304 #[serde(default)]
306 pub hop_budget: Option<i64>,
307 #[serde(default)]
309 pub origin: Option<String>,
310 #[serde(default)]
313 pub deadline: Option<String>,
314 #[serde(default)]
317 pub labels_json: Option<String>,
318}
319
320impl LedgerRow {
321 pub fn from_envelope(envelope: &Envelope, fingerprint: &str) -> StoreResult<Self> {
322 let body_json = serde_json::to_string(&envelope.body)?;
323 let causality = envelope.causality.as_ref();
324 let expires_at = match (envelope.kind, envelope.ttl_ms) {
325 (MsgKind::Note, Some(ttl)) => Some(rfc3339(
326 envelope.ts + chrono::Duration::milliseconds(ttl as i64),
327 )),
328 _ => None,
329 };
330 Ok(Self {
331 msg_id: envelope.id.clone(),
332 op_id: envelope.op_id.clone(),
333 fingerprint: Some(fingerprint.to_string()),
334 kind: envelope.kind,
335 from_json: sender_column(&envelope.from, envelope.admin)?,
336 to_json: serde_json::to_string(&envelope.to)?,
337 task: causality.map(|c| c.task.clone()),
338 parent_task: causality.and_then(|c| c.parent_task.clone()),
339 attempt: causality.map(|c| i64::from(c.attempt)).unwrap_or(0),
340 hop: causality.map(|c| i64::from(c.hop)).unwrap_or(0),
341 state: LedgerState::Queued,
342 out_head: Some(body_head(envelope, &body_json)),
343 reason: None,
344 enqueued_at: rfc3339(envelope.ts),
345 acked_at: None,
346 body_json: Some(body_json),
347 expires_at,
348 requeued: 0,
349 family: causality.and_then(|c| c.family.clone()),
350 hop_budget: causality.and_then(|c| c.hop_budget).map(i64::from),
351 origin: causality.and_then(|c| c.origin.clone()),
352 deadline: causality.and_then(|c| c.deadline).map(rfc3339),
353 labels_json: match causality.and_then(|c| c.labels.as_ref()) {
354 Some(labels) => Some(serde_json::to_string(labels)?),
355 None => None,
356 },
357 })
358 }
359}
360
361impl LedgerRow {
362 pub fn sender(&self) -> Result<Principal, serde_json::Error> {
368 let value: Value = serde_json::from_str(&self.from_json)?;
369 serde_json::from_value(value.get("principal").cloned().unwrap_or(value))
370 }
371
372 pub fn sender_is_admin(&self) -> bool {
374 serde_json::from_str::<Value>(&self.from_json)
375 .ok()
376 .and_then(|value| value.get("admin").and_then(Value::as_bool))
377 .unwrap_or(false)
378 }
379}
380
381fn sender_column(from: &Principal, admin: bool) -> StoreResult<String> {
383 Ok(serde_json::to_string(&serde_json::json!({
384 "admin": admin,
385 "principal": from,
386 }))?)
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390pub enum Append {
391 Accepted(LedgerRow),
392 Duplicate {
393 existing: LedgerRow,
394 fingerprint_matches: bool,
395 },
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399pub struct EventRecord {
400 pub seq: i64,
401 pub kind: String,
402 pub data: Value,
403 pub created_at: String,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct ServerFaultRow {
408 pub id: i64,
409 pub task_id: Option<String>,
410 pub role: Option<String>,
411 pub session_id: Option<String>,
412 pub generation: Option<i64>,
413 pub seq: Option<i64>,
414 pub desired_json: Option<String>,
415 pub observed_json: Option<String>,
416 pub intent: Option<String>,
417 pub attempt: Option<i64>,
418 pub backend_ref: Option<String>,
419 pub kind: String,
420 pub reason: String,
421 pub state: String,
422 pub created_at: i64,
423}
424
425#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
426pub struct FaultQuery {
427 pub task_id: Option<String>,
428 pub role: Option<String>,
429 pub kind: Option<String>,
430 pub open_only: bool,
431 pub limit: u32,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442pub struct GhostSweepRow {
443 pub id: i64,
444 pub task_id: String,
445 pub role: String,
446 pub session_id: String,
447 pub generation: i64,
448 pub seq_before: i64,
449 pub seq_after: i64,
450 pub outcome: Outcome,
452 pub evidence: String,
454 pub swept_at: i64,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458pub struct CursorRow {
459 pub role: String,
460 pub last_msg_id: Option<String>,
461 pub last_seq: i64,
462 pub updated_at: String,
463}
464
465#[derive(Clone, Debug)]
466pub struct ServerLedger {
467 path: PathBuf,
468 retention_days: i64,
469 inner: Arc<Mutex<Connection>>,
471 readers: ReadPool,
474 session_rows_written: Arc<AtomicU64>,
478 live: Arc<LiveSessions>,
480}
481
482impl ServerLedger {
483 pub fn open(path: impl AsRef<Path>, retention_days: u32) -> StoreResult<Self> {
484 let path = path.as_ref().to_path_buf();
485 let conn = open_connection(
486 &path,
487 "server",
488 SERVER_MARKER,
489 SERVER_DDL,
490 SERVER_SCHEMA_VERSION,
491 )?;
492 let readers = ReadPool::open(&path)?;
496 Ok(Self {
497 path,
498 retention_days: i64::from(retention_days).max(1),
499 inner: Arc::new(Mutex::new(conn)),
500 readers,
501 session_rows_written: Arc::new(AtomicU64::new(0)),
502 live: Arc::new(LiveSessions::default()),
503 })
504 }
505
506 pub fn path(&self) -> &Path {
507 &self.path
508 }
509
510 pub fn retention_days(&self) -> i64 {
511 self.retention_days
512 }
513
514 pub fn upsert_role(&self, role: &RoleRow) -> StoreResult<bool> {
515 let conn = self.conn()?;
516 let changed = conn.execute(
517 "INSERT INTO roles(name,key,admin,max_sessions,spec_hash,updated_at) VALUES(?,?,?,?,?,?)
518 ON CONFLICT(name) DO UPDATE SET key=excluded.key,admin=excluded.admin,max_sessions=excluded.max_sessions,spec_hash=excluded.spec_hash,updated_at=excluded.updated_at",
519 params![
520 role.name,
521 role.key,
522 bool_int(role.admin),
523 role.max_sessions,
524 role.spec_hash,
525 unix_to_rfc3339(rfc3339_to_unix(&role.updated_at))
526 ],
527 )?;
528 Ok(changed == 1)
529 }
530
531 pub fn list_roles(&self) -> StoreResult<Vec<RoleRow>> {
532 let conn = self.read()?;
533 let rows = conn
534 .prepare(
535 "SELECT name,key,admin,max_sessions,spec_hash,updated_at FROM roles ORDER BY name",
536 )?
537 .query_map([], role_row)?
538 .collect::<Result<Vec<_>, _>>()?;
539 Ok(rows)
540 }
541
542 pub fn remove_role_missing_from(&self, names: &[String]) -> StoreResult<usize> {
543 let conn = self.conn()?;
544 if names.is_empty() {
545 return Ok(conn.execute("DELETE FROM roles", [])?);
546 }
547 let placeholders = repeat_placeholders(names.len());
548 let sql = format!("DELETE FROM roles WHERE name NOT IN ({placeholders})");
549 let args = names
550 .iter()
551 .cloned()
552 .map(SqlValue::Text)
553 .collect::<Vec<_>>();
554 Ok(conn.execute(&sql, params_from_iter(args))?)
555 }
556
557 pub fn project_session(&self, write: &SessionWrite) -> StoreResult<bool> {
570 let conn = self.conn()?;
571 self.note_session_row_write();
572 let tx = conn.unchecked_transaction()?;
573 let changed = project_session_conn(&tx, write)?;
574 tx.commit()?;
575 if changed {
576 self.live.settle(&write.session_id, write.last_seen);
577 }
578 Ok(changed)
579 }
580
581 pub fn rebind_session(&self, from_session_id: &str, write: &SessionWrite) -> StoreResult<bool> {
590 let conn = self.conn()?;
591 self.note_session_row_write();
592 let tx = conn.unchecked_transaction()?;
593 if from_session_id != write.session_id {
594 tx.execute(
595 "DELETE FROM session_tasks WHERE session_id=?",
596 params![write.session_id],
597 )?;
598 tx.execute(
599 "DELETE FROM sessions WHERE session_id=?",
600 params![write.session_id],
601 )?;
602 tx.execute(
603 "UPDATE session_tasks SET session_id=? WHERE session_id=?",
604 params![write.session_id, from_session_id],
605 )?;
606 tx.execute(
607 "UPDATE sessions SET session_id=? WHERE session_id=?",
608 params![write.session_id, from_session_id],
609 )?;
610 }
611 let changed = project_session_conn(&tx, write)?;
612 tx.commit()?;
613 if changed {
614 self.live.settle(from_session_id, write.last_seen);
617 self.live.settle(&write.session_id, write.last_seen);
618 }
619 Ok(changed)
620 }
621
622 pub fn open_binding(
626 &self,
627 session_id: &str,
628 task_id: &str,
629 bound_at: i64,
630 ) -> StoreResult<bool> {
631 let conn = self.conn()?;
632 Ok(open_binding_conn(&conn, session_id, task_id, bound_at)? > 0)
633 }
634
635 pub fn release_binding(
640 &self,
641 session_id: &str,
642 task_id: &str,
643 released_at: i64,
644 ) -> StoreResult<bool> {
645 let conn = self.conn()?;
646 Ok(release_binding_conn(&conn, session_id, task_id, released_at)? == 1)
647 }
648
649 pub fn open_binding_of(&self, session_id: &str) -> StoreResult<Option<SessionBindingRow>> {
654 let conn = self.read()?;
655 Ok(conn
656 .query_row(
657 "SELECT session_id,task_id,bound_at,released_at FROM session_tasks WHERE session_id=? AND released_at IS NULL ORDER BY bound_at DESC,task_id DESC LIMIT 1",
658 params![session_id],
659 session_binding_row,
660 )
661 .optional()?)
662 }
663
664 pub fn publish_mirror_outcome(
673 &self,
674 session_id: &str,
675 observed_json: &str,
676 expected_observed_json: &str,
677 updated_at: i64,
678 ) -> StoreResult<bool> {
679 let conn = self.conn()?;
680 self.note_session_row_write();
681 let changed = conn.execute(
682 "UPDATE sessions SET observed_json=?,updated_at=? WHERE session_id=? AND observed_json=?",
683 params![
684 observed_json,
685 unix_to_rfc3339(updated_at),
686 session_id,
687 expected_observed_json
688 ],
689 )?;
690 Ok(changed == 1)
691 }
692
693 pub fn beat_session(
711 &self,
712 session_id: &str,
713 at: i64,
714 flush_after_secs: i64,
715 ) -> StoreResult<bool> {
716 self.live.note(session_id, at);
719 let Some(stored) = self.stored_session_row(session_id)? else {
720 return Ok(false);
725 };
726 if at.saturating_sub(stored.last_seen) < flush_after_secs.max(1) {
727 return Ok(false);
728 }
729 let flushed = self.flush_last_seen(session_id, at)?;
730 if flushed {
731 self.live.settle(session_id, at);
732 }
733 Ok(flushed)
734 }
735
736 fn flush_last_seen(&self, session_id: &str, last_seen: i64) -> StoreResult<bool> {
745 let conn = self.conn()?;
746 self.note_session_row_write();
747 let changed = conn.execute(
748 "UPDATE sessions SET last_seen=? WHERE session_id=?",
749 params![unix_to_rfc3339(last_seen), session_id],
750 )?;
751 Ok(changed == 1)
752 }
753
754 pub fn get_session_row(&self, session_id: &str) -> StoreResult<Option<ServerSessionRow>> {
757 Ok(self
758 .stored_session_row(session_id)?
759 .map(|row| self.with_live_seen(row)))
760 }
761
762 fn stored_session_row(&self, session_id: &str) -> StoreResult<Option<ServerSessionRow>> {
768 let conn = self.read()?;
769 let row = conn
770 .query_row(
771 &format!(
772 "SELECT {} FROM sessions WHERE session_id=?",
773 session_columns()
774 ),
775 params![session_id],
776 session_row,
777 )
778 .optional()?;
779 Ok(row)
780 }
781
782 fn with_live_seen(&self, mut row: ServerSessionRow) -> ServerSessionRow {
789 row.last_seen = self.live.freshest(&row.session_id, row.last_seen);
790 row
791 }
792
793 pub fn session_row_for_task(&self, task_id: &str) -> StoreResult<Option<ServerSessionRow>> {
798 let conn = self.read()?;
799 let row = conn
800 .query_row(
801 &format!(
802 "SELECT {} FROM sessions WHERE session_id={SESSION_ID_FOR_TASK}",
803 session_columns()
804 ),
805 params![task_id],
806 session_row,
807 )
808 .optional()?;
809 Ok(row.map(|row| self.with_live_seen(row)))
810 }
811
812 pub fn list_sessions(&self, filter: QuerySessionsArgs) -> StoreResult<Vec<ServerSessionRow>> {
813 let conn = self.read()?;
814 let mut clauses = Vec::new();
815 let mut args = Vec::new();
816 if let Some(task_id) = filter.task_id {
817 clauses.push(
822 "session_id IN (SELECT session_id FROM session_tasks WHERE task_id=?)".to_string(),
823 );
824 args.push(SqlValue::Text(task_id));
825 }
826 if let Some(role) = filter.role {
827 clauses.push("role=?".to_string());
828 args.push(SqlValue::Text(role));
829 }
830 if let Some(lifecycle) = filter.lifecycle {
831 clauses.push(
842 "IFNULL(CASE WHEN json_valid(observed_json) THEN json_extract(observed_json,'$.lifecycle') END,?)=?".to_string(),
843 );
844 args.push(SqlValue::Text(string_tag(&Lifecycle::Created)?));
845 args.push(SqlValue::Text(string_tag(&lifecycle)?));
846 }
847 let where_sql = where_sql(&clauses);
848 let limit = sql_limit(filter.limit);
849 args.push(SqlValue::Integer(limit));
850 let sql = sessions_list_sql(&where_sql);
851 let rows = conn
852 .prepare(&sql)?
853 .query_map(params_from_iter(args), session_row)?
854 .collect::<Result<Vec<_>, _>>()?;
855 Ok(rows
856 .into_iter()
857 .map(|row| self.with_live_seen(row))
858 .collect())
859 }
860
861 pub fn append_ledger(&self, row: &LedgerRow) -> StoreResult<Append> {
862 let conn = self.conn()?;
863 if let Some(op_id) = row.op_id.as_deref() {
864 if let Some(existing) = ledger_by_op_id(&conn, op_id)? {
865 return Ok(Append::Duplicate {
866 fingerprint_matches: existing.fingerprint == row.fingerprint,
867 existing,
868 });
869 }
870 }
871 insert_ledger_row(&conn, row)?;
872 Ok(Append::Accepted(row.clone()))
873 }
874
875 pub fn mark_in_flight(&self, msg_id: &str) -> StoreResult<bool> {
876 self.transition_msg(msg_id, LedgerState::InFlight, None, None)
877 }
878
879 pub fn mark_acked(&self, msg_id: &str, at: DateTime<Utc>) -> StoreResult<bool> {
880 self.transition_msg(msg_id, LedgerState::Acked, Some(rfc3339(at)), None)
881 }
882
883 pub fn mark_rejected(&self, msg_id: &str, reason: &str) -> StoreResult<bool> {
884 self.transition_msg(
885 msg_id,
886 LedgerState::Rejected,
887 None,
888 Some(reason.to_string()),
889 )
890 }
891
892 pub fn expire_queued_before(&self, now: DateTime<Utc>) -> StoreResult<usize> {
893 ensure_transition_allowed(LedgerState::Queued, LedgerState::Expired)?;
894 let conn = self.conn()?;
895 let changed = conn.execute(
896 "UPDATE ledger SET state='expired',reason='expired' WHERE state='queued' AND enqueued_at < ?",
897 params![rfc3339(now)],
898 )?;
899 Ok(changed)
900 }
901
902 pub fn queued_for(&self, role: &str, limit: u32) -> StoreResult<Vec<LedgerRow>> {
903 self.ledger_for_role(role, LedgerState::Queued, limit)
904 }
905
906 pub fn queued_count_for(&self, role: &str) -> StoreResult<u32> {
914 let conn = self.read()?;
915 let count = conn
916 .query_row(
917 "SELECT COUNT(*) FROM ledger WHERE state=? AND json_extract(to_json,'$.role.role')=? AND kind<>?",
918 params![LedgerState::Queued.as_str(), role, MsgKind::Note.as_str()],
919 |row| row.get::<_, u32>(0),
920 )
921 .optional()?
922 .unwrap_or(0);
923 Ok(count)
924 }
925
926 pub fn in_flight_for(&self, role: &str) -> StoreResult<Vec<LedgerRow>> {
927 self.ledger_for_role(role, LedgerState::InFlight, 500)
928 }
929
930 pub fn pending_expiries(&self) -> StoreResult<Vec<(String, DateTime<Utc>)>> {
933 let conn = self.read()?;
934 let rows = conn
935 .prepare(
936 "SELECT msg_id, expires_at FROM ledger WHERE state IN ('queued','in_flight') AND expires_at IS NOT NULL ORDER BY expires_at,rowid",
937 )?
938 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
939 .collect::<Result<Vec<_>, _>>()?;
940 Ok(rows
941 .into_iter()
942 .filter_map(|(msg_id, expires_at)| {
943 DateTime::parse_from_rfc3339(&expires_at)
944 .ok()
945 .map(|deadline| (msg_id, deadline.with_timezone(&Utc)))
946 })
947 .collect())
948 }
949
950 pub fn requeue_in_flight(&self, role: &str) -> StoreResult<usize> {
951 ensure_transition_allowed(LedgerState::InFlight, LedgerState::Queued)?;
952 let conn = self.conn()?;
953 let changed = conn.execute(
954 "UPDATE ledger SET state='queued' WHERE state='in_flight' AND json_extract(to_json,'$.role.role')=?",
955 params![role],
956 )?;
957 Ok(changed)
958 }
959
960 pub fn requeue_one(&self, msg_id: &str) -> StoreResult<LedgerRow> {
964 let conn = self.conn()?;
965 let current = ledger_by_msg_id(&conn, msg_id)?;
966 if current.state == LedgerState::Queued {
967 return Ok(current);
968 }
969 ensure_transition_allowed(current.state, LedgerState::Queued)?;
970 let tx = conn.unchecked_transaction()?;
971 tx.execute(
972 "UPDATE ledger SET state='queued',requeued=requeued+1 WHERE msg_id=?",
973 params![msg_id],
974 )?;
975 let updated = ledger_by_msg_id(&tx, msg_id)?;
976 let event = ledger_state_event(&updated, LedgerState::Queued, None)?;
977 append_event_conn(&tx, "ledger_state", &event)?;
978 tx.commit()?;
979 Ok(updated)
980 }
981
982 pub fn expire_one(&self, msg_id: &str, reason: &str) -> StoreResult<LedgerRow> {
990 let conn = self.conn()?;
991 let current = ledger_by_msg_id(&conn, msg_id)?;
992 if !matches!(current.state, LedgerState::Queued | LedgerState::InFlight) {
993 ensure_transition_allowed(current.state, LedgerState::Expired)?;
994 }
995 let tx = conn.unchecked_transaction()?;
996 tx.execute(
997 "UPDATE ledger SET state='expired',reason=? WHERE msg_id=?",
998 params![reason, msg_id],
999 )?;
1000 let updated = ledger_by_msg_id(&tx, msg_id)?;
1001 let event = ledger_state_event(&updated, LedgerState::Expired, Some(reason.to_string()))?;
1002 append_event_conn(&tx, "ledger_state", &event)?;
1003 tx.commit()?;
1004 Ok(updated)
1005 }
1006
1007 pub fn fail_one(&self, msg_id: &str, reason: &str) -> StoreResult<LedgerRow> {
1011 let conn = self.conn()?;
1012 let current = ledger_by_msg_id(&conn, msg_id)?;
1013 ensure_transition_allowed(current.state, LedgerState::Rejected)?;
1014 let tx = conn.unchecked_transaction()?;
1015 tx.execute(
1016 "UPDATE ledger SET state='rejected',reason=? WHERE msg_id=?",
1017 params![reason, msg_id],
1018 )?;
1019 let updated = ledger_by_msg_id(&tx, msg_id)?;
1020 let event = ledger_state_event(&updated, LedgerState::Rejected, Some(reason.to_string()))?;
1021 append_event_conn(&tx, "ledger_state", &event)?;
1022 tx.commit()?;
1023 Ok(updated)
1024 }
1025
1026 pub fn update_fault_state(
1029 &self,
1030 task_id: &str,
1031 next_state: &str,
1032 reason: &str,
1033 ) -> StoreResult<Vec<ServerFaultRow>> {
1034 let conn = self.conn()?;
1035 let tx = conn.unchecked_transaction()?;
1036 let ids = {
1037 let mut stmt =
1038 tx.prepare("SELECT id FROM faults WHERE task_id=? AND state='open' ORDER BY id")?;
1039 stmt.query_map(params![task_id], |r| r.get::<_, i64>(0))?
1040 .collect::<Result<Vec<_>, _>>()?
1041 };
1042 let mut moved = Vec::with_capacity(ids.len());
1043 for id in ids {
1044 tx.execute(
1045 "UPDATE faults SET state=?,reason=? WHERE id=?",
1046 params![next_state, reason, id],
1047 )?;
1048 let row = fault_row_by_id(&tx, id)?;
1049 let event = fault_event(&row)?;
1050 append_event_conn(&tx, "fault", &event)?;
1051 moved.push(row);
1052 }
1053 tx.commit()?;
1054 Ok(moved)
1055 }
1056
1057 pub fn ledger_query(&self, query: LedgerQuery) -> StoreResult<Vec<LedgerRow>> {
1058 let conn = self.read()?;
1059 let mut clauses = Vec::new();
1060 let mut args = Vec::new();
1061 if let Some(task) = query.task {
1062 clauses.push("task=?".to_string());
1063 args.push(SqlValue::Text(task));
1064 }
1065 if let Some(op_id) = query.op_id {
1066 clauses.push("op_id=?".to_string());
1067 args.push(SqlValue::Text(op_id));
1068 }
1069 if let Some(msg_id) = query.msg_id {
1070 clauses.push("msg_id=?".to_string());
1071 args.push(SqlValue::Text(msg_id));
1072 }
1073 if let Some(role) = query.role {
1074 clauses.push("(json_extract(from_json,'$.principal.role.role')=? OR json_extract(to_json,'$.role.role')=?)".to_string());
1075 args.push(SqlValue::Text(role.clone()));
1076 args.push(SqlValue::Text(role));
1077 }
1078 if let Some(state) = query.state {
1079 clauses.push("state=?".to_string());
1080 args.push(SqlValue::Text(state.as_str().to_string()));
1081 }
1082 if let Some(kind) = query.kind {
1083 clauses.push("kind=?".to_string());
1084 args.push(SqlValue::Text(kind.as_str().to_string()));
1085 }
1086 args.push(SqlValue::Integer(sql_limit(query.limit)));
1087 let sql = ledger_list_sql(&where_sql(&clauses));
1088 let rows = conn
1089 .prepare(&sql)?
1090 .query_map(params_from_iter(args), ledger_row)?
1091 .collect::<Result<Vec<_>, _>>()?;
1092 Ok(rows)
1093 }
1094
1095 pub fn ledger_task(&self, task: &str, limit: u32) -> StoreResult<Vec<LedgerRow>> {
1103 let conn = self.read()?;
1104 let rows = conn
1105 .prepare(&format!(
1106 "SELECT {LEDGER_COLUMNS} FROM ledger WHERE task=? ORDER BY enqueued_at,rowid LIMIT ?"
1107 ))?
1108 .query_map(params![task, sql_limit(limit)], ledger_row)?
1109 .collect::<Result<Vec<_>, _>>()?;
1110 Ok(rows)
1111 }
1112
1113 pub fn append_event(&self, kind: &str, data: &Value) -> StoreResult<i64> {
1114 let conn = self.conn()?;
1115 append_event_conn(&conn, kind, data)
1116 }
1117
1118 pub fn events_since(&self, seq: i64, limit: u32) -> StoreResult<Vec<EventRecord>> {
1119 let conn = self.read()?;
1120 events_since_conn(&conn, seq, limit)
1121 }
1122
1123 pub fn event_head(&self) -> StoreResult<i64> {
1124 let conn = self.read()?;
1125 event_head_conn(&conn)
1126 }
1127
1128 pub fn prune(&self, older_than: DateTime<Utc>) -> StoreResult<usize> {
1129 let conn = self.conn()?;
1130 let changed = conn.execute(
1131 "UPDATE ledger SET body_json=NULL WHERE state='acked' AND acked_at IS NOT NULL AND acked_at < ?",
1132 params![rfc3339(older_than)],
1133 )?;
1134 Ok(changed)
1135 }
1136
1137 pub fn record_fault(&self, fault: &ServerFaultRow) -> StoreResult<i64> {
1138 let conn = self.conn()?;
1139 conn.execute(
1140 "INSERT INTO faults(task_id,role,session_id,generation,seq,desired_json,observed_json,intent,attempt,backend_ref,kind,reason,state,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1141 params![
1142 fault.task_id,
1143 fault.role,
1144 fault.session_id,
1145 fault.generation,
1146 fault.seq,
1147 fault.desired_json,
1148 fault.observed_json,
1149 fault.intent,
1150 fault.attempt,
1151 fault.backend_ref,
1152 fault.kind,
1153 fault.reason,
1154 fault.state,
1155 unix_to_rfc3339(fault.created_at)
1156 ],
1157 )?;
1158 Ok(conn.last_insert_rowid())
1159 }
1160
1161 pub fn open_faults(&self) -> StoreResult<Vec<ServerFaultRow>> {
1162 self.faults_query(FaultQuery {
1163 open_only: true,
1164 limit: 500,
1165 ..FaultQuery::default()
1166 })
1167 }
1168
1169 pub fn ack_fault(&self, fault_id: i64) -> StoreResult<bool> {
1170 let conn = self.conn()?;
1171 Ok(conn.execute(
1172 "UPDATE faults SET state='acked' WHERE id=? AND state<>'acked'",
1173 params![fault_id],
1174 )? == 1)
1175 }
1176
1177 pub fn ack_faults_batch(
1185 &self,
1186 kind: Option<&str>,
1187 before: Option<String>,
1188 limit: u32,
1189 ) -> StoreResult<Vec<i64>> {
1190 if kind.is_none() && before.is_none() {
1191 return Err(rusqlite::Error::InvalidParameterName(
1192 "repair_ack_many needs --kind or --before; refusing every fault".to_string(),
1193 )
1194 .into());
1195 }
1196 let mut conn = self.conn()?;
1197 let tx = conn.transaction()?;
1198 let mut clauses = vec!["state='open'".to_string()];
1199 let mut args: Vec<SqlValue> = Vec::new();
1200 if let Some(kind) = kind {
1201 clauses.push("kind=?".to_string());
1202 args.push(SqlValue::Text(kind.to_string()));
1203 }
1204 if let Some(before) = before {
1205 clauses.push("created_at<?".to_string());
1206 args.push(SqlValue::Text(before));
1207 }
1208 let where_clause = where_sql(&clauses);
1209 args.push(SqlValue::Integer(sql_limit(limit)));
1210 let ids: Vec<i64> = {
1211 let sql = format!("SELECT id FROM faults{where_clause} ORDER BY id LIMIT ?");
1212 let mut stmt = tx.prepare(&sql)?;
1213 stmt.query_map(params_from_iter(&args), |r| r.get::<_, i64>(0))?
1214 .collect::<Result<Vec<_>, _>>()?
1215 };
1216 let mut closed = Vec::new();
1217 for id in ids {
1218 let n = tx.execute(
1219 "UPDATE faults SET state='acked' WHERE id=? AND state='open'",
1220 params![id],
1221 )?;
1222 if n == 1 {
1223 closed.push(id);
1224 }
1225 }
1226 tx.commit()?;
1227 Ok(closed)
1228 }
1229
1230 pub fn faults_query(&self, query: FaultQuery) -> StoreResult<Vec<ServerFaultRow>> {
1231 let conn = self.read()?;
1232 let mut clauses = Vec::new();
1233 let mut args = Vec::new();
1234 if let Some(task_id) = query.task_id {
1235 clauses.push("task_id=?".to_string());
1236 args.push(SqlValue::Text(task_id));
1237 }
1238 if let Some(role) = query.role {
1239 clauses.push("role=?".to_string());
1240 args.push(SqlValue::Text(role));
1241 }
1242 if let Some(kind) = query.kind {
1243 clauses.push("kind=?".to_string());
1244 args.push(SqlValue::Text(kind));
1245 }
1246 if query.open_only {
1247 clauses.push("state='open'".to_string());
1248 }
1249 args.push(SqlValue::Integer(sql_limit(query.limit)));
1250 let sql = format!(
1251 "SELECT {FAULT_COLUMNS} FROM faults{} ORDER BY id LIMIT ?",
1252 where_sql(&clauses)
1253 );
1254 let rows = conn
1255 .prepare(&sql)?
1256 .query_map(params_from_iter(args), server_fault_row)?
1257 .collect::<Result<Vec<_>, _>>()?;
1258 Ok(rows)
1259 }
1260
1261 pub fn faults_query_proto(&self, query: QueryFaultsArgs) -> StoreResult<Vec<ServerFaultRow>> {
1262 self.faults_query(FaultQuery {
1263 task_id: query.task_id,
1264 role: query.role,
1265 kind: query.kind,
1266 open_only: query.open_only,
1267 limit: query.limit,
1268 })
1269 }
1270
1271 pub fn record_ghost_sweep(&self, sweep: &GhostSweepRow) -> StoreResult<i64> {
1273 let conn = self.conn()?;
1274 conn.execute(
1275 "INSERT INTO ghost_sweeps(task_id,role,session_id,generation,seq_before,seq_after,outcome,evidence,swept_at) VALUES(?,?,?,?,?,?,?,?,?)",
1276 params![
1277 sweep.task_id,
1278 sweep.role,
1279 sweep.session_id,
1280 sweep.generation,
1281 sweep.seq_before,
1282 sweep.seq_after,
1283 string_tag(&sweep.outcome)?,
1284 sweep.evidence,
1285 unix_to_rfc3339(sweep.swept_at)
1286 ],
1287 )?;
1288 Ok(conn.last_insert_rowid())
1289 }
1290
1291 pub fn list_ghost_sweeps(&self, limit: u32) -> StoreResult<Vec<GhostSweepRow>> {
1298 let conn = self.read()?;
1299 let rows = conn
1300 .prepare(&ghost_sweeps_list_sql())?
1301 .query_map(params![sql_limit(limit)], ghost_sweep_row)?
1302 .collect::<Result<Vec<_>, _>>()?;
1303 Ok(rows)
1304 }
1305
1306 pub fn cursor_for(&self, role: &str) -> StoreResult<Option<CursorRow>> {
1307 let conn = self.read()?;
1308 Ok(conn
1309 .query_row(
1310 "SELECT role,last_msg_id,last_seq,updated_at FROM inbox_cursors WHERE role=?",
1311 params![role],
1312 cursor_row,
1313 )
1314 .optional()?)
1315 }
1316
1317 pub fn set_cursor(&self, role: &str, msg_id: Option<&str>, seq: i64) -> StoreResult<bool> {
1318 let conn = self.conn()?;
1319 let updated_at = rfc3339(Utc::now());
1320 Ok(conn.execute(
1321 "INSERT INTO inbox_cursors(role,last_msg_id,last_seq,updated_at) VALUES(?,?,?,?)
1322 ON CONFLICT(role) DO UPDATE SET last_msg_id=excluded.last_msg_id,last_seq=excluded.last_seq,updated_at=excluded.updated_at",
1323 params![role, msg_id, seq, updated_at],
1324 )? == 1)
1325 }
1326
1327 pub fn hook_cursor(&self, hook: &str) -> StoreResult<Option<i64>> {
1334 let conn = self.read()?;
1335 Ok(conn
1336 .query_row(
1337 "SELECT last_seq FROM hook_cursors WHERE hook=?",
1338 params![hook],
1339 |row| row.get(0),
1340 )
1341 .optional()?)
1342 }
1343
1344 pub fn set_hook_cursor(&self, hook: &str, seq: i64) -> StoreResult<bool> {
1348 let conn = self.conn()?;
1349 let updated_at = rfc3339(Utc::now());
1350 Ok(conn.execute(
1351 "INSERT INTO hook_cursors(hook,last_seq,updated_at) VALUES(?,?,?)
1352 ON CONFLICT(hook) DO UPDATE SET last_seq=excluded.last_seq,updated_at=excluded.updated_at
1353 WHERE excluded.last_seq > hook_cursors.last_seq",
1354 params![hook, seq, updated_at],
1355 )? == 1)
1356 }
1357
1358 fn transition_msg(
1359 &self,
1360 msg_id: &str,
1361 to: LedgerState,
1362 acked_at: Option<String>,
1363 reason: Option<String>,
1364 ) -> StoreResult<bool> {
1365 let conn = self.conn()?;
1366 let from = current_ledger_state(&conn, msg_id)?;
1367 ensure_transition_allowed(from, to)?;
1368 let changed = conn.execute(
1369 "UPDATE ledger SET state=?,acked_at=COALESCE(?,acked_at),reason=COALESCE(?,reason) WHERE msg_id=?",
1370 params![to.as_str(), acked_at, reason, msg_id],
1371 )?;
1372 Ok(changed == 1)
1373 }
1374
1375 fn ledger_for_role(
1376 &self,
1377 role: &str,
1378 state: LedgerState,
1379 limit: u32,
1380 ) -> StoreResult<Vec<LedgerRow>> {
1381 let conn = self.read()?;
1382 let rows = conn
1383 .prepare(&format!(
1384 "SELECT {LEDGER_COLUMNS} FROM ledger WHERE state=? AND json_extract(to_json,'$.role.role')=? ORDER BY enqueued_at,rowid LIMIT ?"
1385 ))?
1386 .query_map(params![state.as_str(), role, sql_limit(limit)], ledger_row)?
1387 .collect::<Result<Vec<_>, _>>()?;
1388 Ok(rows)
1389 }
1390
1391 fn conn(&self) -> StoreResult<MutexGuard<'_, Connection>> {
1392 self.inner
1393 .lock()
1394 .map_err(|_| StoreError::Sqlite("database mutex poisoned".to_string()))
1395 }
1396
1397 fn read(&self) -> StoreResult<MutexGuard<'_, Connection>> {
1404 self.readers.get()
1405 }
1406
1407 fn note_session_row_write(&self) {
1409 self.session_rows_written.fetch_add(1, Ordering::Relaxed);
1410 }
1411
1412 pub fn session_row_writes(&self) -> u64 {
1421 self.session_rows_written.load(Ordering::Relaxed)
1422 }
1423}
1424
1425const READ_POOL_SIZE: usize = 4;
1434
1435#[derive(Clone, Debug)]
1442struct ReadPool {
1443 path: PathBuf,
1444 conns: Arc<Vec<Mutex<Connection>>>,
1445 next: Arc<AtomicUsize>,
1446}
1447
1448impl ReadPool {
1449 fn open(path: &Path) -> StoreResult<Self> {
1450 let mut conns = Vec::with_capacity(READ_POOL_SIZE);
1451 for _ in 0..READ_POOL_SIZE {
1452 conns.push(Mutex::new(open_reader(path)?));
1453 }
1454 Ok(Self {
1455 path: path.to_path_buf(),
1456 conns: Arc::new(conns),
1457 next: Arc::new(AtomicUsize::new(0)),
1458 })
1459 }
1460
1461 fn get(&self) -> StoreResult<MutexGuard<'_, Connection>> {
1463 let index = self.next.fetch_add(1, Ordering::Relaxed) % self.conns.len();
1464 self.conns[index].lock().map_err(|_| {
1465 StoreError::Sqlite(format!(
1466 "read connection mutex poisoned for {}",
1467 self.path.display()
1468 ))
1469 })
1470 }
1471}
1472
1473pub(crate) fn open_connection(
1477 path: &Path,
1478 which: &'static str,
1479 marker: &str,
1480 ddl: &str,
1481 schema_version: i64,
1482) -> StoreResult<Connection> {
1483 if let Some(parent) = path.parent() {
1484 std::fs::create_dir_all(parent).map_err(|e| StoreError::Sqlite(e.to_string()))?;
1485 }
1486 let conn = Connection::open(path)?;
1487 conn.busy_timeout(Duration::from_millis(5000))?;
1488 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
1489 ensure_schema(&conn, which, marker, ddl, schema_version)?;
1490 Ok(conn)
1491}
1492
1493fn ensure_schema(
1494 conn: &Connection,
1495 which: &'static str,
1496 marker: &str,
1497 ddl: &str,
1498 schema_version: i64,
1499) -> StoreResult<()> {
1500 let tables = user_tables(conn)?;
1501 let legacy: Vec<String> = tables
1502 .iter()
1503 .filter(|name| is_legacy_table(name))
1504 .cloned()
1505 .collect();
1506 if !legacy.is_empty() {
1507 return Err(StoreError::unsupported_schema(
1508 which,
1509 SchemaMismatch::LegacyTables { names: legacy },
1510 ));
1511 }
1512 conn.execute_batch(SCHEMA_MARKER_DDL)?;
1513 let marker_row = conn
1514 .query_row(
1515 "SELECT version,protocol_version FROM schema_marker WHERE name=?",
1516 params![marker],
1517 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
1518 )
1519 .optional()?;
1520 match marker_row {
1521 Some((version, protocol)) if version == schema_version && protocol == PROTOCOL_VERSION => {}
1522 Some((version, protocol)) => {
1523 return Err(if version != schema_version {
1528 StoreError::unsupported_schema(
1529 which,
1530 SchemaMismatch::Version {
1531 found: version,
1532 expected: schema_version,
1533 },
1534 )
1535 } else {
1536 StoreError::unsupported_schema(
1537 which,
1538 SchemaMismatch::Protocol {
1539 found: protocol,
1540 expected: PROTOCOL_VERSION,
1541 },
1542 )
1543 });
1544 }
1545 None => {
1546 let marker_count: i64 =
1547 conn.query_row("SELECT COUNT(*) FROM schema_marker", [], |r| r.get(0))?;
1548 if marker_count > 0 || !tables.is_empty() {
1549 return Err(StoreError::unsupported_schema(
1550 which,
1551 SchemaMismatch::NotEmpty {
1552 tables: tables.len(),
1553 },
1554 ));
1555 }
1556 conn.execute(
1557 "INSERT INTO schema_marker(name,version,protocol_version) VALUES(?,?,?)",
1558 params![marker, schema_version, PROTOCOL_VERSION],
1559 )?;
1560 }
1561 }
1562 conn.execute_batch(ddl)?;
1563 ensure_ledger_expires_at(conn)?;
1564 ensure_ledger_requeued(conn)?;
1565 ensure_ledger_causality_columns(conn)?;
1566 Ok(())
1567}
1568
1569fn open_reader(path: &Path) -> StoreResult<Connection> {
1576 let conn = Connection::open_with_flags(
1577 path,
1578 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1579 )?;
1580 conn.busy_timeout(Duration::from_millis(5000))?;
1581 Ok(conn)
1582}
1583
1584fn ensure_ledger_expires_at(conn: &Connection) -> StoreResult<()> {
1589 let columns = conn
1590 .prepare("PRAGMA table_info(ledger)")?
1591 .query_map([], |row| row.get::<_, String>(1))?
1592 .collect::<Result<Vec<_>, _>>()?;
1593 if columns.is_empty() || columns.iter().any(|name| name == "expires_at") {
1594 return Ok(());
1595 }
1596 conn.execute("ALTER TABLE ledger ADD COLUMN expires_at TEXT", [])?;
1597 Ok(())
1598}
1599
1600fn ensure_ledger_requeued(conn: &Connection) -> StoreResult<()> {
1605 let columns = conn
1606 .prepare("PRAGMA table_info(ledger)")?
1607 .query_map([], |row| row.get::<_, String>(1))?
1608 .collect::<Result<Vec<_>, _>>()?;
1609 if columns.is_empty() || columns.iter().any(|name| name == "requeued") {
1610 return Ok(());
1611 }
1612 conn.execute(
1613 "ALTER TABLE ledger ADD COLUMN requeued INTEGER NOT NULL DEFAULT 0",
1614 [],
1615 )?;
1616 Ok(())
1617}
1618
1619fn ensure_ledger_causality_columns(conn: &Connection) -> StoreResult<()> {
1626 let columns = conn
1627 .prepare("PRAGMA table_info(ledger)")?
1628 .query_map([], |row| row.get::<_, String>(1))?
1629 .collect::<Result<Vec<_>, _>>()?;
1630 if columns.is_empty() {
1631 return Ok(());
1632 }
1633 for (name, kind) in [
1634 ("family", "TEXT"),
1635 ("hop_budget", "INTEGER"),
1636 ("origin", "TEXT"),
1637 ("deadline", "TEXT"),
1638 ("labels_json", "TEXT"),
1639 ] {
1640 if columns.iter().any(|column| column == name) {
1641 continue;
1642 }
1643 conn.execute(&format!("ALTER TABLE ledger ADD COLUMN {name} {kind}"), [])?;
1644 }
1645 Ok(())
1646}
1647
1648fn user_tables(conn: &Connection) -> StoreResult<Vec<String>> {
1649 let rows = conn
1650 .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")?
1651 .query_map([], |row| row.get::<_, String>(0))?
1652 .collect::<Result<Vec<_>, _>>()?;
1653 Ok(rows)
1654}
1655
1656fn is_legacy_table(name: &str) -> bool {
1658 matches!(
1659 name,
1660 "io_cursors" | "loopback_idempotency" | "pending_replies"
1661 ) || name.starts_with("swarm")
1662}
1663
1664pub fn rfc3339(now: DateTime<Utc>) -> String {
1670 now.to_rfc3339_opts(SecondsFormat::Millis, true)
1671}
1672
1673pub(crate) fn append_event_conn(conn: &Connection, kind: &str, data: &Value) -> StoreResult<i64> {
1674 conn.execute(
1675 "INSERT INTO events(type,data_json,created_at) VALUES(?,?,?)",
1676 params![kind, serde_json::to_string(data)?, rfc3339(Utc::now())],
1677 )?;
1678 Ok(conn.last_insert_rowid())
1679}
1680
1681pub(crate) fn events_since_conn(
1682 conn: &Connection,
1683 seq: i64,
1684 limit: u32,
1685) -> StoreResult<Vec<EventRecord>> {
1686 let rows = conn
1687 .prepare(
1688 "SELECT seq,type,data_json,created_at FROM events WHERE seq>? ORDER BY seq LIMIT ?",
1689 )?
1690 .query_map(params![seq, sql_limit(limit)], event_row)?
1691 .collect::<Result<Vec<_>, _>>()?;
1692 Ok(rows)
1693}
1694
1695pub(crate) fn event_head_conn(conn: &Connection) -> StoreResult<i64> {
1696 Ok(conn.query_row("SELECT COALESCE(MAX(seq),0) FROM events", [], |r| r.get(0))?)
1697}
1698
1699fn current_ledger_state(conn: &Connection, msg_id: &str) -> StoreResult<LedgerState> {
1700 let state = conn
1701 .query_row(
1702 "SELECT state FROM ledger WHERE msg_id=?",
1703 params![msg_id],
1704 |r| r.get::<_, String>(0),
1705 )
1706 .optional()?
1707 .ok_or(StoreError::NotFound)?;
1708 parse_ledger_state(&state).ok_or(StoreError::Serialization(format!(
1709 "invalid ledger state {state}"
1710 )))
1711}
1712
1713fn ensure_transition_allowed(from: LedgerState, to: LedgerState) -> StoreResult<()> {
1714 if transition_allowed(from, to) {
1715 return Ok(());
1716 }
1717 Err(StoreError::InvalidState {
1718 from: from.as_str().to_string(),
1719 to: to.as_str().to_string(),
1720 })
1721}
1722
1723fn insert_ledger_row(conn: &Connection, row: &LedgerRow) -> StoreResult<()> {
1724 conn.execute(
1725 "INSERT INTO ledger(msg_id,op_id,fingerprint,kind,from_json,to_json,task,parent_task,attempt,hop,state,out_head,reason,enqueued_at,acked_at,body_json,expires_at,requeued,family,hop_budget,origin,deadline,labels_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1726 params![
1727 row.msg_id,
1728 row.op_id,
1729 row.fingerprint,
1730 row.kind.as_str(),
1731 row.from_json,
1732 row.to_json,
1733 row.task,
1734 row.parent_task,
1735 row.attempt,
1736 row.hop,
1737 row.state.as_str(),
1738 row.out_head,
1739 row.reason,
1740 row.enqueued_at,
1741 row.acked_at,
1742 row.body_json,
1743 row.expires_at,
1744 row.requeued,
1745 row.family,
1746 row.hop_budget,
1747 row.origin,
1748 row.deadline,
1749 row.labels_json
1750 ],
1751 )?;
1752 Ok(())
1753}
1754
1755fn ledger_by_op_id(conn: &Connection, op_id: &str) -> StoreResult<Option<LedgerRow>> {
1756 Ok(conn
1757 .query_row(
1758 &format!("SELECT {LEDGER_COLUMNS} FROM ledger WHERE op_id=?"),
1759 params![op_id],
1760 ledger_row,
1761 )
1762 .optional()?)
1763}
1764
1765const LEDGER_COLUMNS: &str = "msg_id,op_id,fingerprint,kind,from_json,to_json,task,parent_task,attempt,state,out_head,reason,enqueued_at,acked_at,body_json,hop,expires_at,requeued,family,hop_budget,origin,deadline,labels_json";
1766const FAULT_COLUMNS: &str = "id,task_id,role,session_id,generation,seq,desired_json,observed_json,intent,attempt,backend_ref,kind,reason,state,created_at";
1767const GHOST_SWEEP_COLUMNS: &str =
1768 "id,task_id,role,session_id,generation,seq_before,seq_after,outcome,evidence,swept_at";
1769
1770pub(crate) const OPEN_BINDING_TASK: &str =
1778 "(SELECT st.task_id FROM session_tasks st WHERE st.session_id=sessions.session_id
1779 AND st.released_at IS NULL LIMIT 1)";
1780
1781pub(crate) fn session_columns() -> String {
1787 format!(
1788 "session_id,{OPEN_BINDING_TASK},\
1789 role,generation,seq,agent_state,delivery_state,resource_state,recovery_substate,\
1790 desired_json,observed_json,mismatch_count,last_seen,updated_at"
1791 )
1792}
1793
1794pub(crate) const SESSION_ID_FOR_TASK: &str = "(SELECT session_id FROM session_tasks WHERE task_id=?
1800 ORDER BY (released_at IS NULL) DESC, bound_at DESC, session_id DESC LIMIT 1)";
1801
1802fn project_session_conn(conn: &Connection, write: &SessionWrite) -> StoreResult<bool> {
1807 let changed = conn.execute(
1808 "INSERT INTO sessions(session_id,role,generation,seq,agent_state,delivery_state,resource_state,recovery_substate,desired_json,observed_json,mismatch_count,last_seen,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
1809 ON CONFLICT(session_id) DO UPDATE SET role=excluded.role,generation=excluded.generation,seq=excluded.seq,agent_state=excluded.agent_state,delivery_state=excluded.delivery_state,resource_state=excluded.resource_state,recovery_substate=excluded.recovery_substate,desired_json=excluded.desired_json,observed_json=excluded.observed_json,mismatch_count=excluded.mismatch_count,last_seen=excluded.last_seen,updated_at=excluded.updated_at
1810 WHERE excluded.generation > sessions.generation OR (excluded.generation = sessions.generation AND excluded.seq > sessions.seq)",
1811 params![
1812 write.session_id,
1813 write.role,
1814 write.generation,
1815 write.seq,
1816 write.agent_state,
1817 write.delivery_state,
1818 write.resource_state,
1819 write.recovery_substate,
1820 write.desired_json,
1821 write.observed_json,
1822 write.mismatch_count,
1823 unix_to_rfc3339(write.last_seen),
1824 unix_to_rfc3339(write.updated_at)
1825 ],
1826 )?;
1827 if changed == 1 {
1828 if let Some(task_id) = write.task_id.as_deref() {
1829 open_binding_conn(conn, &write.session_id, task_id, write.last_seen)?;
1830 }
1831 }
1832 Ok(changed == 1)
1833}
1834
1835pub(crate) fn open_binding_conn(
1843 conn: &Connection,
1844 session_id: &str,
1845 task_id: &str,
1846 bound_at: i64,
1847) -> StoreResult<usize> {
1848 let at = unix_to_rfc3339(bound_at);
1849 conn.execute(
1850 "UPDATE session_tasks SET released_at=? WHERE session_id=? AND released_at IS NULL AND task_id<>?",
1851 params![at, session_id, task_id],
1852 )?;
1853 Ok(conn.execute(
1854 "INSERT INTO session_tasks(session_id,task_id,bound_at,released_at) VALUES(?,?,?,NULL)
1855 ON CONFLICT(session_id,task_id) DO UPDATE SET bound_at=excluded.bound_at,released_at=NULL
1856 WHERE session_tasks.released_at IS NOT NULL",
1857 params![session_id, task_id, at],
1858 )?)
1859}
1860
1861pub(crate) fn release_binding_conn(
1864 conn: &Connection,
1865 session_id: &str,
1866 task_id: &str,
1867 released_at: i64,
1868) -> StoreResult<usize> {
1869 Ok(conn.execute(
1870 "UPDATE session_tasks SET released_at=? WHERE session_id=? AND task_id=? AND released_at IS NULL",
1871 params![unix_to_rfc3339(released_at), session_id, task_id],
1872 )?)
1873}
1874
1875fn sessions_list_sql(where_sql: &str) -> String {
1880 format!(
1881 "SELECT {} FROM sessions{where_sql} ORDER BY updated_at DESC,rowid DESC LIMIT ?",
1882 session_columns()
1883 )
1884}
1885
1886fn ledger_list_sql(where_sql: &str) -> String {
1888 format!(
1889 "SELECT {LEDGER_COLUMNS} FROM ledger{where_sql} ORDER BY enqueued_at DESC,rowid DESC LIMIT ?"
1890 )
1891}
1892
1893fn ghost_sweeps_list_sql() -> String {
1896 format!(
1897 "SELECT {GHOST_SWEEP_COLUMNS} FROM ghost_sweeps ORDER BY swept_at DESC,rowid DESC LIMIT ?"
1898 )
1899}
1900
1901fn ledger_row(r: &Row<'_>) -> rusqlite::Result<LedgerRow> {
1902 let kind: String = r.get(3)?;
1903 let state: String = r.get(9)?;
1904 Ok(LedgerRow {
1905 msg_id: r.get(0)?,
1906 op_id: r.get(1)?,
1907 fingerprint: r.get(2)?,
1908 kind: parse_msg_kind(&kind)
1909 .ok_or_else(|| conversion_error(3, format!("invalid message kind {kind}")))?,
1910 from_json: r.get(4)?,
1911 to_json: r.get(5)?,
1912 task: r.get(6)?,
1913 parent_task: r.get(7)?,
1914 attempt: r.get(8)?,
1915 state: parse_ledger_state(&state)
1916 .ok_or_else(|| conversion_error(9, format!("invalid ledger state {state}")))?,
1917 out_head: r.get(10)?,
1918 reason: r.get(11)?,
1919 enqueued_at: r.get(12)?,
1920 acked_at: r.get(13)?,
1921 body_json: r.get(14)?,
1922 hop: r.get(15)?,
1923 expires_at: r.get(16)?,
1924 requeued: r.get(17)?,
1925 family: r.get(18)?,
1926 hop_budget: r.get(19)?,
1927 origin: r.get(20)?,
1928 deadline: r.get(21)?,
1929 labels_json: r.get(22)?,
1930 })
1931}
1932
1933fn role_row(r: &Row<'_>) -> rusqlite::Result<RoleRow> {
1934 let admin: i64 = r.get(2)?;
1935 Ok(RoleRow {
1936 name: r.get(0)?,
1937 key: r.get(1)?,
1938 admin: admin != 0,
1939 max_sessions: r.get(3)?,
1940 spec_hash: r.get(4)?,
1941 updated_at: r.get(5)?,
1942 })
1943}
1944
1945fn session_row(r: &Row<'_>) -> rusqlite::Result<ServerSessionRow> {
1948 Ok(ServerSessionRow {
1949 session_id: r.get(0)?,
1950 task_id: r.get(1)?,
1951 role: r.get(2)?,
1952 generation: r.get(3)?,
1953 seq: r.get(4)?,
1954 agent_state: r.get(5)?,
1955 delivery_state: r.get(6)?,
1956 resource_state: r.get(7)?,
1957 recovery_substate: r.get(8)?,
1958 desired_json: r.get(9)?,
1959 observed_json: r.get(10)?,
1960 mismatch_count: r.get(11)?,
1961 last_seen: rfc3339_to_unix(&r.get::<_, String>(12)?),
1962 updated_at: rfc3339_to_unix(&r.get::<_, String>(13)?),
1963 })
1964}
1965
1966fn session_binding_row(r: &Row<'_>) -> rusqlite::Result<SessionBindingRow> {
1967 Ok(SessionBindingRow {
1968 session_id: r.get(0)?,
1969 task_id: r.get(1)?,
1970 bound_at: rfc3339_to_unix(&r.get::<_, String>(2)?),
1971 released_at: r
1972 .get::<_, Option<String>>(3)?
1973 .map(|text| rfc3339_to_unix(&text)),
1974 })
1975}
1976
1977fn event_row(r: &Row<'_>) -> rusqlite::Result<EventRecord> {
1978 let text: String = r.get(2)?;
1979 let data = serde_json::from_str(&text).map_err(|e| conversion_error(2, e.to_string()))?;
1980 Ok(EventRecord {
1981 seq: r.get(0)?,
1982 kind: r.get(1)?,
1983 data,
1984 created_at: r.get(3)?,
1985 })
1986}
1987
1988fn server_fault_row(r: &Row<'_>) -> rusqlite::Result<ServerFaultRow> {
1989 Ok(ServerFaultRow {
1990 id: r.get(0)?,
1991 task_id: r.get(1)?,
1992 role: r.get(2)?,
1993 session_id: r.get(3)?,
1994 generation: r.get(4)?,
1995 seq: r.get(5)?,
1996 desired_json: r.get(6)?,
1997 observed_json: r.get(7)?,
1998 intent: r.get(8)?,
1999 attempt: r.get(9)?,
2000 backend_ref: r.get(10)?,
2001 kind: r.get(11)?,
2002 reason: r.get(12)?,
2003 state: r.get(13)?,
2004 created_at: rfc3339_to_unix(&r.get::<_, String>(14)?),
2005 })
2006}
2007
2008fn ghost_sweep_row(r: &Row<'_>) -> rusqlite::Result<GhostSweepRow> {
2009 let outcome: String = r.get(7)?;
2010 Ok(GhostSweepRow {
2011 id: r.get(0)?,
2012 task_id: r.get(1)?,
2013 role: r.get(2)?,
2014 session_id: r.get(3)?,
2015 generation: r.get(4)?,
2016 seq_before: r.get(5)?,
2017 seq_after: r.get(6)?,
2018 outcome: parse_outcome(&outcome)
2019 .ok_or_else(|| conversion_error(7, format!("invalid outcome {outcome}")))?,
2020 evidence: r.get(8)?,
2021 swept_at: rfc3339_to_unix(&r.get::<_, String>(9)?),
2022 })
2023}
2024
2025fn ledger_by_msg_id(conn: &Connection, msg_id: &str) -> StoreResult<LedgerRow> {
2026 conn.query_row(
2027 &format!("SELECT {LEDGER_COLUMNS} FROM ledger WHERE msg_id=?"),
2028 params![msg_id],
2029 ledger_row,
2030 )
2031 .optional()?
2032 .ok_or(StoreError::NotFound)
2033}
2034
2035fn fault_row_by_id(conn: &Connection, id: i64) -> StoreResult<ServerFaultRow> {
2036 conn.query_row(
2037 &format!("SELECT {FAULT_COLUMNS} FROM faults WHERE id=?"),
2038 params![id],
2039 server_fault_row,
2040 )
2041 .optional()?
2042 .ok_or(StoreError::NotFound)
2043}
2044
2045fn ledger_state_event(
2048 row: &LedgerRow,
2049 state: LedgerState,
2050 reason: Option<String>,
2051) -> StoreResult<Value> {
2052 let event = Event::LedgerState(LedgerStateEvent {
2053 msg_id: row.msg_id.clone(),
2054 op_id: row.op_id.clone(),
2055 kind: row.kind,
2056 from: row.sender().unwrap_or_else(|_| Principal::role("unknown")),
2057 to: serde_json::from_str(&row.to_json).unwrap_or_else(|_| Principal::role("unknown")),
2058 task: row.task.clone(),
2059 state,
2060 outcome: None,
2061 reason,
2062 });
2063 Ok(serde_json::to_value(&event)?)
2064}
2065
2066fn fault_event(row: &ServerFaultRow) -> StoreResult<Value> {
2068 let event = Event::Fault(FaultEvent {
2069 id: row.id,
2070 task_id: row.task_id.clone(),
2071 role: row.role.clone(),
2072 session_id: row.session_id.clone(),
2073 generation: row.generation.map(|value| value.max(0) as u64),
2074 seq: row.seq.map(|value| value.max(0) as u64),
2075 kind: row.kind.clone(),
2076 reason: row.reason.clone(),
2077 desired: row
2078 .desired_json
2079 .as_deref()
2080 .and_then(|text| serde_json::from_str(text).ok()),
2081 observed: row
2082 .observed_json
2083 .as_deref()
2084 .and_then(|text| serde_json::from_str(text).ok()),
2085 intent: row.intent.clone(),
2086 attempt: row.attempt.map(|value| value.max(0) as u64),
2087 backend_ref: row
2088 .backend_ref
2089 .as_deref()
2090 .and_then(|text| serde_json::from_str(text).ok()),
2091 state: Some(row.state.clone()),
2092 created_at: Some(row.created_at),
2093 });
2094 Ok(serde_json::to_value(&event)?)
2095}
2096
2097fn cursor_row(r: &Row<'_>) -> rusqlite::Result<CursorRow> {
2098 Ok(CursorRow {
2099 role: r.get(0)?,
2100 last_msg_id: r.get(1)?,
2101 last_seq: r.get(2)?,
2102 updated_at: r.get(3)?,
2103 })
2104}
2105
2106fn bool_int(value: bool) -> i64 {
2107 if value { 1 } else { 0 }
2108}
2109
2110pub const OUT_HEAD_CLUSTERS: usize = 200;
2112
2113pub fn head_preview(text: &str) -> String {
2120 text.graphemes(true).take(OUT_HEAD_CLUSTERS).collect()
2121}
2122
2123fn body_head(envelope: &Envelope, body_json: &str) -> String {
2124 if let Some(head) = envelope.body.head.as_deref().filter(|h| !h.is_empty()) {
2131 return head_preview(head);
2132 }
2133 let text = envelope.body.text.as_deref().unwrap_or(body_json);
2134 head_preview(text)
2135}
2136
2137pub(crate) fn unix_to_rfc3339(seconds: i64) -> String {
2141 DateTime::from_timestamp(seconds, 0)
2142 .map(rfc3339)
2143 .unwrap_or_else(|| rfc3339(DateTime::UNIX_EPOCH))
2144}
2145
2146pub(crate) fn rfc3339_to_unix(text: &str) -> i64 {
2148 DateTime::parse_from_rfc3339(text)
2149 .map(|value| value.timestamp())
2150 .unwrap_or(0)
2151}
2152
2153fn parse_msg_kind(value: &str) -> Option<MsgKind> {
2154 match value {
2155 "task" => Some(MsgKind::Task),
2156 "completion" => Some(MsgKind::Completion),
2157 "note" => Some(MsgKind::Note),
2158 "control" => Some(MsgKind::Control),
2159 _ => None,
2160 }
2161}
2162
2163fn parse_ledger_state(value: &str) -> Option<LedgerState> {
2164 match value {
2165 "queued" => Some(LedgerState::Queued),
2166 "in_flight" => Some(LedgerState::InFlight),
2167 "acked" => Some(LedgerState::Acked),
2168 "rejected" => Some(LedgerState::Rejected),
2169 "expired" => Some(LedgerState::Expired),
2170 _ => None,
2171 }
2172}
2173
2174fn parse_outcome(value: &str) -> Option<Outcome> {
2175 match value {
2176 "done" => Some(Outcome::Done),
2177 "failed" => Some(Outcome::Failed),
2178 "cancelled" => Some(Outcome::Cancelled),
2179 "blocked" => Some(Outcome::Blocked),
2180 _ => None,
2181 }
2182}
2183
2184pub(crate) fn string_tag<T: Serialize>(value: &T) -> StoreResult<String> {
2185 match serde_json::to_value(value)? {
2186 Value::String(text) => Ok(text),
2187 other => Err(StoreError::Serialization(format!(
2188 "state serialized as {other}"
2189 ))),
2190 }
2191}
2192
2193fn sql_limit(limit: u32) -> i64 {
2194 if limit == 0 {
2195 DEFAULT_LIMIT
2196 } else {
2197 i64::from(limit.min(500))
2198 }
2199}
2200
2201fn repeat_placeholders(len: usize) -> String {
2202 std::iter::repeat_n("?", len).collect::<Vec<_>>().join(",")
2203}
2204
2205fn where_sql(clauses: &[String]) -> String {
2206 if clauses.is_empty() {
2207 String::new()
2208 } else {
2209 format!(" WHERE {}", clauses.join(" AND "))
2210 }
2211}
2212
2213fn conversion_error(index: usize, message: String) -> rusqlite::Error {
2214 rusqlite::Error::FromSqlConversionFailure(
2215 index,
2216 Type::Text,
2217 Box::new(StoreError::Serialization(message)),
2218 )
2219}