Skip to main content

khive_db/stores/
entity.rs

1//! SQL-backed `EntityStore` implementation.
2
3use std::collections::HashSet;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use rusqlite::OptionalExtension;
8use uuid::Uuid;
9
10use khive_storage::attachment::{Attachment, AttachmentSubstrate};
11use khive_storage::entity::{Entity, EntityFilter};
12use khive_storage::error::{StorageError, WriterTaskRequestState};
13use khive_storage::types::{
14    BatchWriteSummary, DeleteMode, Page, PageRequest, SeekCursor, SeekPage, SqlStatement, SqlValue,
15};
16use khive_storage::EntityStore;
17use khive_storage::StorageCapability;
18
19use crate::error::SqliteError;
20use crate::pool::ConnectionPool;
21use crate::sql_bridge::bind_params;
22use crate::stores::attachment::{attachment_upsert_statement, delete_record_attachments_statement};
23use crate::writer_task::{execute_wrapped_transaction, WriterTaskHandle};
24
25fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
26    StorageError::driver(StorageCapability::Entities, op, e)
27}
28
29fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
30    StorageError::driver(StorageCapability::Entities, op, e)
31}
32
33const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
34
35const ENTITY_SELECT_COLUMNS: &str =
36    "entities.id, entities.namespace, entities.kind, entities.entity_type, entities.name, \
37     entities.description, entities.properties, entities.tags, entities.created_at, \
38     entities.updated_at, entities.deleted_at, entities.merged_into, entities.merge_event_id, \
39     (SELECT attachment.content_ref FROM attachments AS attachment \
40      WHERE attachment.record_uuid = entities.id \
41        AND attachment.substrate = 'entity' AND attachment.role = 'content') AS content_ref, entities.version";
42
43// ---------------------------------------------------------------------------
44// Pure statement builders (ADR-099 B3 r6 structural cut)
45//
46// These carry NO I/O — they turn an already-computed `Entity` (or a bare id)
47// into the exact `SqlStatement` this store executes. `upsert_entity` and
48// `delete_entity` below call them and execute the result; ADR-099's atomic
49// prepare path (`khive-runtime`) calls them too, to build the same statement
50// for its own guarded, synchronous apply. One statement generator, two
51// execution mechanisms (async trait dispatch vs. synchronous atomic unit) —
52// per ADR-099's accepted "handler-logic-duplication objection" text, the
53// bulk-apply path reuses the handler's existing statement generation instead
54// of re-deriving it.
55// ---------------------------------------------------------------------------
56
57/// Insert at version one, or replace the fields and advance the existing row once.
58pub fn entity_upsert_statement(entity: &Entity) -> SqlStatement {
59    let mut statement = entity_write_statement(entity, "INSERT", "entity-upsert");
60    statement.sql.push_str(
61        " ON CONFLICT(id) DO UPDATE SET namespace=excluded.namespace, kind=excluded.kind, \
62         entity_type=excluded.entity_type, name=excluded.name, description=excluded.description, \
63         properties=excluded.properties, tags=excluded.tags, created_at=excluded.created_at, \
64         updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, \
65         merged_into=excluded.merged_into, merge_event_id=excluded.merge_event_id, \
66         version=entities.version+1",
67    );
68    statement
69}
70
71/// Insert a new entity without replacing an existing live or deleted row.
72/// A competing ID causes a constraint error, so a prepared create cannot
73/// overwrite a row committed after its absence check.
74pub fn entity_insert_statement(entity: &Entity) -> SqlStatement {
75    entity_write_statement(entity, "INSERT", "entity-insert")
76}
77
78fn entity_write_statement(entity: &Entity, insert: &str, label: &str) -> SqlStatement {
79    let properties_str = entity
80        .properties
81        .as_ref()
82        .map(|v| serde_json::to_string(v).unwrap_or_default());
83    let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
84    SqlStatement {
85        sql: format!(
86            "{insert} INTO entities \
87              (id, namespace, kind, entity_type, name, description, properties, tags, \
88               created_at, updated_at, deleted_at, merged_into, merge_event_id) \
89              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"
90        ),
91        params: vec![
92            SqlValue::Text(entity.id.to_string()),
93            SqlValue::Text(entity.namespace.clone()),
94            SqlValue::Text(entity.kind.clone()),
95            match &entity.entity_type {
96                Some(t) => SqlValue::Text(t.clone()),
97                None => SqlValue::Null,
98            },
99            SqlValue::Text(entity.name.clone()),
100            match &entity.description {
101                Some(d) => SqlValue::Text(d.clone()),
102                None => SqlValue::Null,
103            },
104            match properties_str {
105                Some(p) => SqlValue::Text(p),
106                None => SqlValue::Null,
107            },
108            SqlValue::Text(tags_str),
109            SqlValue::Integer(entity.created_at),
110            SqlValue::Integer(entity.updated_at),
111            match entity.deleted_at {
112                Some(d) => SqlValue::Integer(d),
113                None => SqlValue::Null,
114            },
115            match entity.merged_into {
116                Some(u) => SqlValue::Text(u.to_string()),
117                None => SqlValue::Null,
118            },
119            match entity.merge_event_id {
120                Some(u) => SqlValue::Text(u.to_string()),
121                None => SqlValue::Null,
122            },
123        ],
124        label: Some(label.to_string()),
125    }
126}
127
128/// Conditional-insert companion to [`entity_upsert_statement`]. Every
129/// conflict leaves the existing row untouched so a caller can read the
130/// winner and explicitly reapply its intended delta.
131pub fn entity_insert_if_absent_statement(entity: &Entity) -> SqlStatement {
132    let mut statement = entity_upsert_statement(entity);
133    statement.sql = "INSERT INTO entities \
134              (id, namespace, kind, entity_type, name, description, properties, tags, \
135               created_at, updated_at, deleted_at, merged_into, merge_event_id) \
136              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
137              ON CONFLICT DO NOTHING"
138        .to_string();
139    statement.label = Some("entity-insert-if-absent".to_string());
140    statement
141}
142
143/// Full-entity compare-and-swap update used after caller-side normalization
144/// was derived from a read snapshot. Unlike [`entity_upsert_statement`], this
145/// never inserts and cannot overwrite a row whose revision or deletion
146/// marker moved after the snapshot was read. The replacement revision must
147/// also be strictly greater than the persisted snapshot timestamp. The
148/// replacement's `version` is the expected persisted snapshot revision; the
149/// UPDATE advances it exactly once. Timestamp equality
150/// is a refused CAS, never a successful write with an unchanged concurrency
151/// token. Mirrors `note_replace_if_unchanged_statement`
152/// (`crates/khive-db/src/stores/note.rs`).
153pub fn entity_replace_if_unchanged_statement(
154    entity: &Entity,
155    expected_updated_at: i64,
156    expected_deleted_at: Option<i64>,
157) -> SqlStatement {
158    let properties_str = entity
159        .properties
160        .as_ref()
161        .map(|v| serde_json::to_string(v).unwrap_or_default());
162    let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
163    SqlStatement {
164        sql: "UPDATE entities SET \
165                namespace = ?1, kind = ?2, entity_type = ?3, name = ?4, description = ?5, \
166                properties = ?6, tags = ?7, updated_at = ?8, deleted_at = ?9, \
167                merged_into = ?10, merge_event_id = ?11, version = version + 1 \
168              WHERE id = ?12 AND updated_at = ?13 AND deleted_at IS ?14 \
169                AND ?8 > updated_at AND version = ?15"
170            .to_string(),
171        params: vec![
172            SqlValue::Text(entity.namespace.clone()),
173            SqlValue::Text(entity.kind.clone()),
174            match &entity.entity_type {
175                Some(t) => SqlValue::Text(t.clone()),
176                None => SqlValue::Null,
177            },
178            SqlValue::Text(entity.name.clone()),
179            match &entity.description {
180                Some(d) => SqlValue::Text(d.clone()),
181                None => SqlValue::Null,
182            },
183            match properties_str {
184                Some(p) => SqlValue::Text(p),
185                None => SqlValue::Null,
186            },
187            SqlValue::Text(tags_str),
188            SqlValue::Integer(entity.updated_at),
189            match entity.deleted_at {
190                Some(d) => SqlValue::Integer(d),
191                None => SqlValue::Null,
192            },
193            match entity.merged_into {
194                Some(u) => SqlValue::Text(u.to_string()),
195                None => SqlValue::Null,
196            },
197            match entity.merge_event_id {
198                Some(u) => SqlValue::Text(u.to_string()),
199                None => SqlValue::Null,
200            },
201            SqlValue::Text(entity.id.to_string()),
202            SqlValue::Integer(expected_updated_at),
203            match expected_deleted_at {
204                Some(value) => SqlValue::Integer(value),
205                None => SqlValue::Null,
206            },
207            SqlValue::Integer(entity.version),
208        ],
209        label: Some("entity-replace-if-unchanged".to_string()),
210    }
211}
212
213/// The exact soft-delete `UPDATE` this store's `delete_entity(Soft)` issues.
214pub fn entity_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
215    SqlStatement {
216        sql: "UPDATE entities SET deleted_at = ?1, version = version + 1 WHERE id = ?2 AND deleted_at IS NULL".to_string(),
217        params: vec![
218            SqlValue::Integer(deleted_at),
219            SqlValue::Text(id.to_string()),
220        ],
221        label: Some("entity-delete-soft".to_string()),
222    }
223}
224
225/// The exact hard-delete `DELETE` this store's `delete_entity(Hard)` issues
226/// (no `deleted_at` predicate — purges live and already-tombstoned rows).
227pub fn entity_hard_delete_statement(id: Uuid) -> SqlStatement {
228    SqlStatement {
229        sql: "DELETE FROM entities WHERE id = ?1".to_string(),
230        params: vec![SqlValue::Text(id.to_string())],
231        label: Some("entity-delete-hard".to_string()),
232    }
233}
234
235/// An EntityStore backed by SQLite. Namespace is the caller's responsibility.
236///
237/// UUID is globally unique — get/delete by ID alone. Query/count use the
238/// namespace parameter as passed. Read routing is always pool-backed; the
239/// constructor's legacy file-backed flag is retained for API compatibility.
240pub struct SqlEntityStore {
241    pool: Arc<ConnectionPool>,
242    writer_task: Option<WriterTaskHandle>,
243}
244
245impl SqlEntityStore {
246    /// Create a new store.
247    ///
248    /// When `KHIVE_WRITE_QUEUE=1` (`PoolConfig::write_queue_enabled`), every
249    /// write path on this store — the batch `upsert_entities` (its own
250    /// explicit flag check) AND every single-row write routed through the
251    /// shared `with_writer` helper (`upsert_entity`, `delete_entity`) —
252    /// routes through the pool-wide `WriterTask`
253    /// (`ConnectionPool::writer_task_handle`) instead of the legacy
254    /// pool-mutex path. The handle is a clone of the ONE writer task owned
255    /// by `pool` — constructing multiple stores (or multiple namespaces)
256    /// over the same pool never spawns more than one writer task; see
257    /// `ConnectionPool::writer_task_handle`'s doc comment for why that
258    /// matters. `None` (falling back to the legacy path for every write)
259    /// if the resolved flag is off, or if the writer task failed to spawn
260    /// (for example, an in-memory pool, which has no standalone-connection
261    /// support) — enabled by default for file-backed pools; explicit
262    /// off/degraded fallback remains possible.
263    pub fn new(pool: Arc<ConnectionPool>, _is_file_backed: bool) -> Self {
264        // Enabled by default for file-backed pools; explicit off/degraded
265        // fallback remains possible: a missing writer task — whether
266        // explicitly disabled, spawn degraded (e.g. in-memory pool), or no
267        // Tokio runtime was available at this first access (ADR-067
268        // Component A runtime-handle guard) — is cached without failing
269        // construction. Every write re-resolves it; strict mode refuses a
270        // remaining miss and compatibility mode may use the legacy path.
271        let writer_task = pool.writer_task_handle().ok().flatten();
272
273        Self { pool, writer_task }
274    }
275
276    fn current_writer_task(
277        &self,
278        operation: &'static str,
279    ) -> Result<Option<WriterTaskHandle>, StorageError> {
280        self.pool
281            .writer_task_for_write(self.writer_task.as_ref(), operation)
282    }
283
284    /// Route a single-row write through the pool-wide `WriterTask` when
285    /// the write queue is enabled and a handle is available. Strict mode
286    /// refuses a missing handle; compatibility mode falls back to the legacy
287    /// pool-mutex path.
288    ///
289    /// ADR-067 Component A (Fork C slice 2): this is the ONE routing point
290    /// for every `with_writer` caller in this store — `upsert_entity`,
291    /// `delete_entity` (soft/hard) all reach the WriterTask through this
292    /// helper rather than each duplicating the flag check. `f` must be
293    /// DML-only (a single statement, no bare `BEGIN IMMEDIATE`): on the
294    /// flag-on path it runs inside the WriterTask's own transaction, and a
295    /// nested `BEGIN IMMEDIATE` would violate SQLite's nested-transaction
296    /// rule. `upsert_entities` (the batch method) performs the same write-time
297    /// lookup first; a non-strict `None` then falls through this helper, which
298    /// records the actual compatibility fallback. Strict mode returns before
299    /// either direct-writer seam is reached.
300    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
301    where
302        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
303        R: Send + 'static,
304    {
305        if let Some(writer_task) = self.current_writer_task(op)? {
306            return writer_task
307                .send_bounded(move |conn| f(conn).map_err(|e| map_err(e, op)))
308                .await;
309        }
310
311        self.pool
312            .record_direct_route(crate::timeout_sink::Site::DirectRouteEntity);
313        let pool = Arc::clone(&self.pool);
314        tokio::task::spawn_blocking(move || {
315            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
316            f(guard.conn()).map_err(|e| map_err(e, op))
317        })
318        .await
319        .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
320    }
321
322    /// Route multi-statement entity mutations through one write transaction.
323    /// The writer task already supplies that transaction; the direct fallback
324    /// opens and closes its own fail-closed `BEGIN IMMEDIATE` unit.
325    async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
326    where
327        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
328        R: Send + 'static,
329    {
330        if let Some(writer_task) = self.current_writer_task(op)? {
331            return writer_task
332                .send_bounded(move |conn| f(conn).map_err(|error| map_err(error, op)))
333                .await;
334        }
335
336        self.pool
337            .record_direct_route(crate::timeout_sink::Site::DirectRouteEntity);
338        let pool = Arc::clone(&self.pool);
339        tokio::task::spawn_blocking(move || {
340            let guard = pool
341                .try_writer()
342                .map_err(|error| map_sqlite_err(error, op))?;
343            let conn = guard.conn();
344            if !conn.is_autocommit() {
345                pool.retire_pooled_writer(conn);
346                return Err(StorageError::WriterTaskTerminated {
347                    request_state: WriterTaskRequestState::SideEffectsUnknown,
348                });
349            }
350            if let Err(begin_error) = conn.execute_batch("BEGIN IMMEDIATE") {
351                if !conn.is_autocommit() {
352                    pool.retire_pooled_writer(conn);
353                    return Err(StorageError::WriterTaskTerminated {
354                        request_state: WriterTaskRequestState::SideEffectsUnknown,
355                    });
356                }
357                return Err(map_err(begin_error, op));
358            }
359
360            let (result, terminal_state) = execute_wrapped_transaction(conn, op, move |conn| {
361                f(conn).map_err(|error| map_err(error, op))
362            });
363            if terminal_state.is_some() {
364                pool.retire_pooled_writer(conn);
365            }
366            result
367        })
368        .await
369        .map_err(|error| StorageError::driver(StorageCapability::Entities, op, error))?
370    }
371
372    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
373    where
374        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
375        R: Send + 'static,
376    {
377        super::run_pooled_store_read(
378            Arc::clone(&self.pool),
379            StorageCapability::Entities,
380            op,
381            move |conn| f(conn).map_err(|error| map_err(error, op)),
382        )
383        .await
384    }
385}
386
387// =============================================================================
388// Helpers
389// =============================================================================
390
391fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
392    let id_str: String = row.get(0)?;
393    let namespace: String = row.get(1)?;
394    let kind: String = row.get(2)?;
395    let entity_type: Option<String> = row.get(3)?;
396    let name: String = row.get(4)?;
397    let description: Option<String> = row.get(5)?;
398    let properties_str: Option<String> = row.get(6)?;
399    let tags_str: String = row.get(7)?;
400    let created_at: i64 = row.get(8)?;
401    let updated_at: i64 = row.get(9)?;
402    let deleted_at: Option<i64> = row.get(10)?;
403    let merged_into_str: Option<String> = row.get(11)?;
404    let merge_event_id_str: Option<String> = row.get(12)?;
405    let content_ref: Option<String> = row.get(13)?;
406    let version: i64 = row.get(14)?;
407
408    let id = parse_uuid(&id_str)?;
409
410    let properties = properties_str
411        .map(|s| {
412            serde_json::from_str(&s).map_err(|e| {
413                rusqlite::Error::FromSqlConversionFailure(
414                    6,
415                    rusqlite::types::Type::Text,
416                    Box::new(e),
417                )
418            })
419        })
420        .transpose()?;
421
422    let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
423        rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
424    })?;
425
426    let merged_into = merged_into_str
427        .as_deref()
428        .map(Uuid::parse_str)
429        .transpose()
430        .map_err(|e| {
431            rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
432        })?;
433
434    let merge_event_id = merge_event_id_str
435        .as_deref()
436        .map(Uuid::parse_str)
437        .transpose()
438        .map_err(|e| {
439            rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
440        })?;
441
442    Ok(Entity {
443        id,
444        namespace,
445        kind,
446        entity_type,
447        name,
448        description,
449        properties,
450        tags,
451        created_at,
452        updated_at,
453        version,
454        deleted_at,
455        merged_into,
456        merge_event_id,
457        content_ref,
458    })
459}
460
461/// DML-only batch upsert loop shared by both the legacy (flag-off) and
462/// WriterTask-routed (flag-on) `upsert_entities` paths (ADR-067 slice 1).
463///
464/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
465/// enclosing transaction. Per-row failures are captured into
466/// `BatchWriteSummary::failed`/`first_error` rather than aborting the loop,
467/// matching the existing partial-success contract: this function's own
468/// `Result` is `Ok` unless a caller bug is present, since no branch here
469/// returns `Err`.
470fn batch_upsert_entities(
471    conn: &rusqlite::Connection,
472    entities: &[Entity],
473    attempted: u64,
474) -> Result<BatchWriteSummary, rusqlite::Error> {
475    let mut summary = BatchWriteSummary {
476        attempted,
477        ..BatchWriteSummary::default()
478    };
479
480    for (index, entity) in entities.iter().enumerate() {
481        let id_str = entity.id.to_string();
482        let statement = entity_upsert_statement(entity);
483        let result = (|| {
484            let mut prepared = conn.prepare(&statement.sql)?;
485            bind_params(&mut prepared, &statement.params)?;
486            prepared.raw_execute()
487        })();
488        match result {
489            Ok(_) => summary.affected = summary.affected.saturating_add(1),
490            Err(e) => {
491                let (class, retryability) = super::classify_batch_sqlite_error(&e);
492                summary.record_failure(index, Some(id_str), class, retryability, e.to_string());
493            }
494        }
495    }
496
497    Ok(summary)
498}
499
500fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
501    Uuid::parse_str(s).map_err(|e| {
502        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
503    })
504}
505
506/// Escape SQLite `LIKE` wildcard characters (`%`, `_`) and the escape
507/// character itself (`\`) so a caller-supplied name is matched literally
508/// under `LIKE ... ESCAPE '\'` rather than as a pattern (#818: an
509/// entity named e.g. `a_b` must not also match `aXb`, and a name containing
510/// `%` must not silently widen into a broad substring scan).
511fn escape_like(input: &str) -> String {
512    let mut out = String::with_capacity(input.len());
513    for c in input.chars() {
514        if matches!(c, '\\' | '%' | '_') {
515            out.push('\\');
516        }
517        out.push(c);
518    }
519    out
520}
521
522fn build_entity_where(
523    namespace: &str,
524    filter: &EntityFilter,
525) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
526    // When filter.namespaces is non-empty use `namespace IN (...)` so that
527    // multi-namespace read visibility works.  Otherwise fall back to the
528    // single-namespace equality check for backward compatibility.
529    let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
530        if !filter.namespaces.is_empty() {
531            let placeholders: Vec<String> = (1..=filter.namespaces.len())
532                .map(|i| format!("?{i}"))
533                .collect();
534            let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
535                .namespaces
536                .iter()
537                .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
538                .collect();
539            (
540                format!("namespace IN ({})", placeholders.join(", ")),
541                params,
542            )
543        } else {
544            (
545                "namespace = ?1".to_string(),
546                vec![Box::new(namespace.to_string())],
547            )
548        };
549
550    let mut conditions: Vec<String> = vec![ns_condition, "deleted_at IS NULL".to_string()];
551    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
552
553    if !filter.ids.is_empty() {
554        let placeholders: Vec<String> = filter
555            .ids
556            .iter()
557            .map(|id| {
558                params.push(Box::new(id.to_string()));
559                format!("?{}", params.len())
560            })
561            .collect();
562        conditions.push(format!("id IN ({})", placeholders.join(", ")));
563    }
564
565    if !filter.kinds.is_empty() {
566        let placeholders: Vec<String> = filter
567            .kinds
568            .iter()
569            .map(|k| {
570                params.push(Box::new(k.clone()));
571                format!("?{}", params.len())
572            })
573            .collect();
574        conditions.push(format!("kind IN ({})", placeholders.join(", ")));
575    }
576
577    let type_scope = conditions.join(" AND ");
578    let type_predicate = |scope: &str, placeholders: &str| {
579        if filter.legacy_entity_type_fallback {
580            // Legacy properties can contain invalid JSON. Exclude those rows
581            // from type fallback without rewriting them. Keep json_valid as
582            // an explicit term matching the partial legacy-type index;
583            // json_type alone does not imply its validity predicate to SQLite.
584            format!(
585                "id IN (SELECT id FROM entities WHERE {scope} \
586                 AND entity_type IN ({placeholders}) \
587                 UNION ALL SELECT id FROM entities WHERE {scope} \
588                 AND entity_type IS NULL AND json_valid(properties) \
589                 AND json_type(properties, '$.type') = 'text' \
590                 AND json_extract(properties, '$.type') IN ({placeholders}))"
591            )
592        } else {
593            format!("entity_type IN ({placeholders})")
594        }
595    };
596    if !filter.entity_types.is_empty() {
597        let placeholders: Vec<String> = filter
598            .entity_types
599            .iter()
600            .map(|t| {
601                params.push(Box::new(t.clone()));
602                format!("?{}", params.len())
603            })
604            .collect();
605        conditions.push(type_predicate(&type_scope, &placeholders.join(", ")));
606    }
607
608    if !filter.entity_types_by_kind.is_empty() {
609        let mut groups = Vec::new();
610        for (kind, types) in &filter.entity_types_by_kind {
611            if types.is_empty() {
612                continue;
613            }
614            params.push(Box::new(kind.clone()));
615            let kind_param = params.len();
616            let placeholders = types
617                .iter()
618                .map(|value| {
619                    params.push(Box::new(value.clone()));
620                    format!("?{}", params.len())
621                })
622                .collect::<Vec<_>>()
623                .join(", ");
624            let scope = format!("{type_scope} AND kind = ?{kind_param}");
625            let predicate = type_predicate(&scope, &placeholders);
626            groups.push(format!("(kind = ?{kind_param} AND {predicate})"));
627        }
628        conditions.push(if groups.is_empty() {
629            "0".to_string()
630        } else {
631            format!("({})", groups.join(" OR "))
632        });
633    }
634
635    if let Some(ref prefix) = filter.name_prefix {
636        params.push(Box::new(format!("{}%", escape_like(prefix))));
637        conditions.push(format!("name LIKE ?{} ESCAPE '\\'", params.len()));
638    }
639
640    if let Some(ref exact) = filter.name_exact {
641        params.push(Box::new(exact.clone()));
642        // `entities.name` has no `COLLATE NOCASE` (see sql/schema.sql), so
643        // `=` is already SQLite's default case-sensitive BINARY comparison.
644        // `COLLATE BINARY` is spelled out here so this predicate stays
645        // correct even if the column's default collation ever changes.
646        conditions.push(format!("name = ?{} COLLATE BINARY", params.len()));
647    }
648
649    if !filter.names_ci.is_empty() {
650        // ADR-104 Stage C, R1: one batched `LOWER(name) IN (...)` predicate,
651        // served by `idx_entities_namespace_name_ci (namespace, LOWER(name))`.
652        let placeholders: Vec<String> = filter
653            .names_ci
654            .iter()
655            .map(|n| {
656                params.push(Box::new(n.to_ascii_lowercase()));
657                format!("?{}", params.len())
658            })
659            .collect();
660        conditions.push(format!("LOWER(name) IN ({})", placeholders.join(", ")));
661    }
662
663    if !filter.tags_any.is_empty() {
664        let placeholders: Vec<String> = filter
665            .tags_any
666            .iter()
667            .map(|t| {
668                // Normalise to lowercase so the comparison is case-insensitive
669                // domain filter must be case-insensitive.
670                params.push(Box::new(t.to_lowercase()));
671                format!("?{}", params.len())
672            })
673            .collect();
674        conditions.push(format!(
675            "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
676            placeholders.join(", ")
677        ));
678    }
679
680    let clause = format!(" WHERE {}", conditions.join(" AND "));
681    (clause, params)
682}
683
684fn build_candidate_entity_query(
685    columns: &str,
686    where_sql: &str,
687    candidate_param_indices: &[usize],
688    order_by: &str,
689    limit_idx: usize,
690    offset_idx: usize,
691) -> String {
692    let candidate_rows = candidate_param_indices
693        .iter()
694        .map(|idx| format!("(?{idx})"))
695        .collect::<Vec<_>>()
696        .join(", ");
697
698    format!(
699        "WITH candidates(folded_name) AS (VALUES {candidate_rows}), \
700         matched_entities(entity_id) AS (\
701             SELECT (\
702                 SELECT id FROM entities{where_sql} \
703                 AND LOWER(name) = candidates.folded_name LIMIT 1\
704             ) FROM candidates\
705         ) \
706         SELECT {columns} FROM entities \
707         JOIN matched_entities ON entities.id = matched_entities.entity_id \
708         ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
709    )
710}
711
712fn is_complete_id_lookup(filter: &EntityFilter, page: &PageRequest) -> bool {
713    !filter.ids.is_empty()
714        && filter.kinds.is_empty()
715        && filter.entity_types.is_empty()
716        && filter.entity_types_by_kind.is_empty()
717        && filter.name_prefix.is_none()
718        && filter.name_exact.is_none()
719        && filter.tags_any.is_empty()
720        && filter.names_ci.is_empty()
721        && page.offset == 0
722        && usize::try_from(page.limit).ok() == Some(filter.ids.len())
723}
724
725// =============================================================================
726// EntityStore implementation
727// =============================================================================
728
729#[async_trait]
730impl EntityStore for SqlEntityStore {
731    async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
732        let statement = entity_upsert_statement(&entity);
733        self.with_writer("upsert_entity", move |conn| {
734            let mut stmt = conn.prepare(&statement.sql)?;
735            bind_params(&mut stmt, &statement.params)?;
736            stmt.raw_execute()?;
737            Ok(())
738        })
739        .await
740    }
741
742    async fn insert_entity_if_absent(&self, entity: Entity) -> Result<bool, StorageError> {
743        let statement = entity_insert_if_absent_statement(&entity);
744        self.with_writer("insert_entity_if_absent", move |conn| {
745            let mut stmt = conn.prepare(&statement.sql)?;
746            bind_params(&mut stmt, &statement.params)?;
747            Ok(stmt.raw_execute()? > 0)
748        })
749        .await
750    }
751
752    async fn upsert_entity_with_attachments(
753        &self,
754        entity: Entity,
755        attachments: Vec<Attachment>,
756    ) -> Result<(), StorageError> {
757        let entity_id = entity.id;
758        let entity_statement = entity_upsert_statement(&entity);
759        let mut attachment_statements = Vec::with_capacity(attachments.len());
760        for attachment in attachments {
761            attachment.validate()?;
762            if attachment.record_uuid != entity_id
763                || attachment.substrate != AttachmentSubstrate::Entity
764            {
765                return Err(StorageError::InvalidInput {
766                    capability: StorageCapability::Attachments,
767                    operation: "upsert_entity_with_attachments".into(),
768                    message: format!(
769                        "attachment {} must target entity {entity_id}",
770                        attachment.role
771                    ),
772                });
773            }
774            attachment_statements.push(attachment_upsert_statement(&attachment)?);
775        }
776
777        self.with_writer_tx("upsert_entity_with_attachments", move |conn| {
778            let mut entity_stmt = conn.prepare(&entity_statement.sql)?;
779            bind_params(&mut entity_stmt, &entity_statement.params)?;
780            entity_stmt.raw_execute()?;
781            drop(entity_stmt);
782
783            for statement in attachment_statements {
784                let mut stmt = conn.prepare(&statement.sql)?;
785                bind_params(&mut stmt, &statement.params)?;
786                stmt.raw_execute()?;
787            }
788            Ok(())
789        })
790        .await
791    }
792
793    async fn upsert_entities(
794        &self,
795        entities: Vec<Entity>,
796    ) -> Result<BatchWriteSummary, StorageError> {
797        let attempted = entities.len() as u64;
798
799        // ADR-067 slice 1: when the write queue is enabled, route through
800        // the WriterTask channel. The closure is DML-only — no BEGIN
801        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
802        // owns the transaction and `WriteRequest::execute_and_reply` owns
803        // the commit/rollback decision (a bare BEGIN IMMEDIATE inside this
804        // closure would violate SQLite's nested-transaction rule).
805        if let Some(writer_task) = self.current_writer_task("upsert_entities")? {
806            return writer_task
807                .send_bounded(move |conn| {
808                    batch_upsert_entities(conn, &entities, attempted)
809                        .map_err(|e| map_err(e, "upsert_entities"))
810                })
811                .await;
812        }
813
814        // Explicitly disabled or degraded fallback path: byte-for-byte unchanged from pre-ADR-067
815        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT/ROLLBACK
816        // via the pool-mutex writer.
817        let origin = self.pool.origin();
818        self.with_writer("upsert_entities", move |conn| {
819            conn.execute_batch("BEGIN IMMEDIATE")?;
820            let _tx_handle = khive_storage::tx_registry::register_scoped(
821                Some("entity_upsert_batch".to_string()),
822                origin,
823            );
824
825            let summary = batch_upsert_entities(conn, &entities, attempted)?;
826
827            if let Err(e) = conn.execute_batch("COMMIT") {
828                let _ = conn.execute_batch("ROLLBACK");
829                return Err(e);
830            }
831            Ok(summary)
832        })
833        .await
834    }
835
836    async fn replace_entity_if_unchanged(
837        &self,
838        entity: Entity,
839        expected_updated_at: i64,
840        expected_deleted_at: Option<i64>,
841    ) -> Result<bool, StorageError> {
842        let statement = entity_replace_if_unchanged_statement(
843            &entity,
844            expected_updated_at,
845            expected_deleted_at,
846        );
847        self.with_writer("replace_entity_if_unchanged", move |conn| {
848            let mut stmt = conn.prepare(&statement.sql)?;
849            bind_params(&mut stmt, &statement.params)?;
850            Ok(stmt.raw_execute()? > 0)
851        })
852        .await
853    }
854
855    async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
856        let id_str = id.to_string();
857
858        self.with_reader("get_entity", move |conn| {
859            let sql = format!(
860                "SELECT {ENTITY_SELECT_COLUMNS} FROM entities \
861                 WHERE entities.id = ?1 AND entities.deleted_at IS NULL"
862            );
863            let mut stmt = conn.prepare(&sql)?;
864            let mut rows = stmt.query(rusqlite::params![id_str])?;
865            match rows.next()? {
866                Some(row) => Ok(Some(read_entity(row)?)),
867                None => Ok(None),
868            }
869        })
870        .await
871    }
872
873    async fn entity_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
874        let id = id.to_string();
875        self.with_reader("entity_sequence", move |conn| {
876            conn.query_row(
877                "SELECT seq FROM entities_seq WHERE entity_id = ?1",
878                rusqlite::params![id],
879                |row| row.get(0),
880            )
881            .optional()
882        })
883        .await
884    }
885
886    async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
887        match mode {
888            DeleteMode::Soft => {
889                let now = chrono::Utc::now().timestamp_micros();
890                let statement = entity_soft_delete_statement(id, now);
891                self.with_writer("delete_entity_soft", move |conn| {
892                    let mut stmt = conn.prepare(&statement.sql)?;
893                    bind_params(&mut stmt, &statement.params)?;
894                    Ok(stmt.raw_execute()? > 0)
895                })
896                .await
897            }
898            DeleteMode::Hard => {
899                let entity_statement = entity_hard_delete_statement(id);
900                let attachment_statement =
901                    delete_record_attachments_statement(id, AttachmentSubstrate::Entity);
902                self.with_writer_tx("delete_entity_hard", move |conn| {
903                    let mut entity_stmt = conn.prepare(&entity_statement.sql)?;
904                    bind_params(&mut entity_stmt, &entity_statement.params)?;
905                    let deleted = entity_stmt.raw_execute()? > 0;
906                    drop(entity_stmt);
907                    if deleted {
908                        let mut attachment_stmt = conn.prepare(&attachment_statement.sql)?;
909                        bind_params(&mut attachment_stmt, &attachment_statement.params)?;
910                        attachment_stmt.raw_execute()?;
911                    }
912                    Ok(deleted)
913                })
914                .await
915            }
916        }
917    }
918
919    async fn query_entities(
920        &self,
921        namespace: &str,
922        filter: EntityFilter,
923        page: PageRequest,
924    ) -> Result<Page<Entity>, StorageError> {
925        let namespace = namespace.to_string();
926        let skip_total = is_complete_id_lookup(&filter, &page);
927        let limit_i64 = i64::from(page.limit);
928        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
929            capability: StorageCapability::Entities,
930            operation: "query_entities".into(),
931            message: format!(
932                "PageRequest: offset must be <= i64::MAX, got {}",
933                page.offset
934            ),
935        })?;
936
937        self.with_reader("query_entities", move |conn| {
938            let total = if filter.names_ci.is_empty() && !skip_total {
939                let (count_sql, count_params) = build_entity_where(&namespace, &filter);
940                let sql = format!("SELECT COUNT(*) FROM entities{count_sql}");
941                let mut stmt = conn.prepare(&sql)?;
942                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
943                    count_params.iter().map(|p| p.as_ref()).collect();
944                Some(stmt.query_row(param_refs.as_slice(), |row| row.get::<_, i64>(0))? as u64)
945            } else {
946                None
947            };
948
949            let mut lookup_filter = filter.clone();
950            lookup_filter.names_ci.clear();
951            let effective_filter = if filter.names_ci.is_empty() {
952                &filter
953            } else {
954                &lookup_filter
955            };
956            let (where_sql, mut data_params) = build_entity_where(&namespace, effective_filter);
957
958            let candidate_param_indices = if filter.names_ci.is_empty() {
959                Vec::new()
960            } else {
961                let mut candidates: Vec<String> = filter
962                    .names_ci
963                    .iter()
964                    .map(|name| name.to_ascii_lowercase())
965                    .collect();
966                candidates.sort_unstable();
967                candidates.dedup();
968                candidates
969                    .into_iter()
970                    .map(|candidate| {
971                        data_params.push(Box::new(candidate));
972                        data_params.len()
973                    })
974                    .collect()
975            };
976
977            // #818: when a name_prefix filter is active, an exact
978            // ASCII-case-insensitive match must never be pushed out of the page by
979            // pattern candidates that merely share the prefix. Rank exact
980            // matches first (deterministic tiebreak via created_at) so page
981            // truncation can never hide the record a caller resolved by name.
982            let order_by = if let Some(ref prefix) = filter.name_prefix {
983                data_params.push(Box::new(prefix.to_ascii_lowercase()));
984                format!(
985                    "CASE WHEN LOWER(name) = ?{} THEN 0 ELSE 1 END, created_at DESC, id DESC",
986                    data_params.len()
987                )
988            } else {
989                // #1671: append `id` as the final tiebreak in the primary
990                // key's direction so equal-`created_at` rows keep a fixed
991                // order across page boundaries. The deterministic total order
992                // removes tie-order instability only — offset paging can still
993                // duplicate or skip rows under concurrent inserts/deletes or
994                // sort-key updates (that would need snapshot isolation or
995                // keyset pagination).
996                "created_at DESC, id DESC".to_string()
997            };
998
999            data_params.push(Box::new(limit_i64));
1000            data_params.push(Box::new(offset_i64));
1001
1002            let limit_idx = data_params.len() - 1;
1003            let offset_idx = data_params.len();
1004
1005            let columns = ENTITY_SELECT_COLUMNS;
1006            let data_sql = if filter.names_ci.is_empty() {
1007                format!(
1008                    "SELECT {columns} FROM entities{where_sql} \
1009                     ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
1010                )
1011            } else {
1012                build_candidate_entity_query(
1013                    columns,
1014                    &where_sql,
1015                    &candidate_param_indices,
1016                    &order_by,
1017                    limit_idx,
1018                    offset_idx,
1019                )
1020            };
1021
1022            let mut stmt = conn.prepare(&data_sql)?;
1023            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1024                data_params.iter().map(|p| p.as_ref()).collect();
1025            let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
1026
1027            let mut items = Vec::new();
1028            for row in rows {
1029                items.push(row?);
1030            }
1031
1032            Ok(Page { items, total })
1033        })
1034        .await
1035    }
1036
1037    async fn query_entities_after(
1038        &self,
1039        namespace: &str,
1040        filter: EntityFilter,
1041        after: Option<SeekCursor>,
1042        limit: u32,
1043    ) -> Result<SeekPage<Entity>, StorageError> {
1044        if limit == 0 {
1045            return Ok(SeekPage::default());
1046        }
1047        if !filter.names_ci.is_empty() {
1048            return Err(StorageError::InvalidInput {
1049                capability: StorageCapability::Entities,
1050                operation: "query_entities_after".into(),
1051                message: "names_ci candidate folding is not compatible with seek pagination".into(),
1052            });
1053        }
1054
1055        let namespace = namespace.to_string();
1056        let limit_usize = limit as usize;
1057        let probe_limit_i64 = i64::from(limit) + 1;
1058        self.with_reader("query_entities_after", move |conn| {
1059            let (mut where_sql, mut params) = build_entity_where(&namespace, &filter);
1060            if let Some(cursor) = after {
1061                params.push(Box::new(cursor.sequence));
1062                where_sql.push_str(&format!(" AND entities_seq.seq > ?{}", params.len()));
1063            }
1064            params.push(Box::new(probe_limit_i64));
1065            let limit_idx = params.len();
1066
1067            let columns = ENTITY_SELECT_COLUMNS;
1068            // CROSS JOIN is load-bearing for the unfiltered walk: SQLite must
1069            // drive the query from the sequence INTEGER PRIMARY KEY range
1070            // instead of scanning the namespace index and sorting all matches
1071            // into a temp B-tree. When a kind filter is active, forcing that
1072            // same seq-first plan prevents SQLite from using
1073            // idx_entities_kind(namespace, kind) as the driving index, so a
1074            // plain JOIN is used instead and left to the query planner — the
1075            // cursor's page order is still entities_seq.seq, unchanged.
1076            let join_kind = if filter.kinds.is_empty() {
1077                "CROSS JOIN"
1078            } else {
1079                "JOIN"
1080            };
1081            let sql = format!(
1082                "SELECT {columns}, entities_seq.seq FROM entities_seq \
1083                 {join_kind} entities ON entities.id = entities_seq.entity_id{where_sql} \
1084                 ORDER BY entities_seq.seq ASC LIMIT ?{limit_idx}"
1085            );
1086            let mut stmt = conn.prepare(&sql)?;
1087            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1088                params.iter().map(|param| param.as_ref()).collect();
1089            let rows = stmt.query_map(param_refs.as_slice(), |row| {
1090                Ok((read_entity(row)?, row.get::<_, i64>(15)?))
1091            })?;
1092            let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
1093            let has_more = entries.len() > limit_usize;
1094            if has_more {
1095                entries.truncate(limit_usize);
1096            }
1097            let next_after = if has_more {
1098                entries.last().map(|(entity, sequence)| SeekCursor {
1099                    sequence: *sequence,
1100                    id: entity.id,
1101                })
1102            } else {
1103                None
1104            };
1105            let items = entries.into_iter().map(|(entity, _)| entity).collect();
1106            Ok(SeekPage { items, next_after })
1107        })
1108        .await
1109    }
1110
1111    async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
1112        let id_str = id.to_string();
1113
1114        self.with_reader("get_entity_including_deleted", move |conn| {
1115            let sql =
1116                format!("SELECT {ENTITY_SELECT_COLUMNS} FROM entities WHERE entities.id = ?1");
1117            let mut stmt = conn.prepare(&sql)?;
1118            let mut rows = stmt.query(rusqlite::params![id_str])?;
1119            match rows.next()? {
1120                Some(row) => Ok(Some(read_entity(row)?)),
1121                None => Ok(None),
1122            }
1123        })
1124        .await
1125    }
1126
1127    async fn count_entities(
1128        &self,
1129        namespace: &str,
1130        filter: EntityFilter,
1131    ) -> Result<u64, StorageError> {
1132        let namespace = namespace.to_string();
1133
1134        self.with_reader("count_entities", move |conn| {
1135            if filter.namespaces.is_empty() {
1136                let (where_sql, params) = build_entity_where(&namespace, &filter);
1137                let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
1138                let mut stmt = conn.prepare(&sql)?;
1139                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1140                    params.iter().map(|p| p.as_ref()).collect();
1141                let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1142                return Ok(count as u64);
1143            }
1144
1145            let deduped_namespaces: Vec<String> = filter
1146                .namespaces
1147                .iter()
1148                .cloned()
1149                .collect::<HashSet<_>>()
1150                .into_iter()
1151                .collect();
1152
1153            let mut total = 0;
1154            for chunk in deduped_namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1155                let chunk_filter = EntityFilter {
1156                    namespaces: chunk.to_vec(),
1157                    ..filter.clone()
1158                };
1159                let (where_sql, params) = build_entity_where(&namespace, &chunk_filter);
1160                let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
1161                let mut stmt = conn.prepare(&sql)?;
1162                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1163                    params.iter().map(|p| p.as_ref()).collect();
1164                let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1165                total += count as u64;
1166            }
1167            Ok(total)
1168        })
1169        .await
1170    }
1171}
1172
1173// =============================================================================
1174// DDL
1175// =============================================================================
1176
1177const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
1178
1179pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1180    conn.execute_batch(ENTITIES_DDL)
1181}
1182
1183#[cfg(test)]
1184#[path = "entity_tests.rs"]
1185mod tests;