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, EntityTombstones, EntityTypeCounts};
12use khive_storage::error::StorageError;
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    e.into_storage_error(StorageCapability::Entities, op)
31}
32
33const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
34
35const ENTITIES_COUNT_BY_TYPE_SQL: &str = include_str!("../../sql/entities-count-by-type.sql");
36
37const ENTITY_SELECT_COLUMNS: &str =
38    "entities.id, entities.namespace, entities.kind, entities.entity_type, entities.name, \
39     entities.description, entities.properties, entities.tags, entities.created_at, \
40     entities.updated_at, entities.deleted_at, entities.merged_into, entities.merge_event_id, \
41     (SELECT attachment.content_ref FROM attachments AS attachment \
42      WHERE attachment.record_uuid = entities.id \
43        AND attachment.substrate = 'entity' AND attachment.role = 'content') AS content_ref, entities.version";
44
45// ---------------------------------------------------------------------------
46// Pure statement builders (ADR-099 B3 r6 structural cut)
47//
48// These carry NO I/O — they turn an already-computed `Entity` (or a bare id)
49// into the exact `SqlStatement` this store executes. `upsert_entity` and
50// `delete_entity` below call them and execute the result; ADR-099's atomic
51// prepare path (`khive-runtime`) calls them too, to build the same statement
52// for its own guarded, synchronous apply. One statement generator, two
53// execution mechanisms (async trait dispatch vs. synchronous atomic unit) —
54// per ADR-099's accepted "handler-logic-duplication objection" text, the
55// bulk-apply path reuses the handler's existing statement generation instead
56// of re-deriving it.
57// ---------------------------------------------------------------------------
58
59/// Insert at version one, or replace the fields and advance the existing row once.
60pub fn entity_upsert_statement(entity: &Entity) -> SqlStatement {
61    let mut statement = entity_write_statement(entity, "INSERT", "entity-upsert");
62    statement.sql.push_str(
63        " ON CONFLICT(id) DO UPDATE SET namespace=excluded.namespace, kind=excluded.kind, \
64         entity_type=excluded.entity_type, name=excluded.name, description=excluded.description, \
65         properties=excluded.properties, tags=excluded.tags, created_at=excluded.created_at, \
66         updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, \
67         merged_into=excluded.merged_into, merge_event_id=excluded.merge_event_id, \
68         version=entities.version+1",
69    );
70    statement
71}
72
73fn entity_write_statement(entity: &Entity, insert: &str, label: &str) -> SqlStatement {
74    let properties_str = entity
75        .properties
76        .as_ref()
77        .map(|v| serde_json::to_string(v).unwrap_or_default());
78    let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
79    SqlStatement {
80        sql: format!(
81            "{insert} INTO entities \
82              (id, namespace, kind, entity_type, name, description, properties, tags, \
83               created_at, updated_at, deleted_at, merged_into, merge_event_id) \
84              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"
85        ),
86        params: vec![
87            SqlValue::Text(entity.id.to_string()),
88            SqlValue::Text(entity.namespace.clone()),
89            SqlValue::Text(entity.kind.clone()),
90            match &entity.entity_type {
91                Some(t) => SqlValue::Text(t.clone()),
92                None => SqlValue::Null,
93            },
94            SqlValue::Text(entity.name.clone()),
95            match &entity.description {
96                Some(d) => SqlValue::Text(d.clone()),
97                None => SqlValue::Null,
98            },
99            match properties_str {
100                Some(p) => SqlValue::Text(p),
101                None => SqlValue::Null,
102            },
103            SqlValue::Text(tags_str),
104            SqlValue::Integer(entity.created_at),
105            SqlValue::Integer(entity.updated_at),
106            match entity.deleted_at {
107                Some(d) => SqlValue::Integer(d),
108                None => SqlValue::Null,
109            },
110            match entity.merged_into {
111                Some(u) => SqlValue::Text(u.to_string()),
112                None => SqlValue::Null,
113            },
114            match entity.merge_event_id {
115                Some(u) => SqlValue::Text(u.to_string()),
116                None => SqlValue::Null,
117            },
118        ],
119        label: Some(label.to_string()),
120    }
121}
122
123/// Conditional-insert companion to [`entity_upsert_statement`]. Every
124/// conflict leaves the existing row untouched so a caller can read the
125/// winner and explicitly reapply its intended delta.
126pub fn entity_insert_if_absent_statement(entity: &Entity) -> SqlStatement {
127    let mut statement = entity_upsert_statement(entity);
128    statement.sql = "INSERT INTO entities \
129              (id, namespace, kind, entity_type, name, description, properties, tags, \
130               created_at, updated_at, deleted_at, merged_into, merge_event_id) \
131              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
132              ON CONFLICT DO NOTHING"
133        .to_string();
134    statement.label = Some("entity-insert-if-absent".to_string());
135    statement
136}
137
138/// Full-entity compare-and-swap update used after caller-side normalization
139/// was derived from a read snapshot. Unlike [`entity_upsert_statement`], this
140/// never inserts and cannot overwrite a row whose revision or deletion
141/// marker moved after the snapshot was read. The replacement revision must
142/// also be strictly greater than the persisted snapshot timestamp. The
143/// replacement's `version` is the expected persisted snapshot revision; the
144/// UPDATE advances it exactly once. Timestamp equality
145/// is a refused CAS, never a successful write with an unchanged concurrency
146/// token. Mirrors `note_replace_if_unchanged_statement`
147/// (`crates/khive-db/src/stores/note.rs`).
148pub fn entity_replace_if_unchanged_statement(
149    entity: &Entity,
150    expected_updated_at: i64,
151    expected_deleted_at: Option<i64>,
152) -> SqlStatement {
153    let properties_str = entity
154        .properties
155        .as_ref()
156        .map(|v| serde_json::to_string(v).unwrap_or_default());
157    let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
158    SqlStatement {
159        sql: "UPDATE entities SET \
160                namespace = ?1, kind = ?2, entity_type = ?3, name = ?4, description = ?5, \
161                properties = ?6, tags = ?7, updated_at = ?8, deleted_at = ?9, \
162                merged_into = ?10, merge_event_id = ?11, version = version + 1 \
163              WHERE id = ?12 AND updated_at = ?13 AND deleted_at IS ?14 \
164                AND ?8 > updated_at AND version = ?15"
165            .to_string(),
166        params: vec![
167            SqlValue::Text(entity.namespace.clone()),
168            SqlValue::Text(entity.kind.clone()),
169            match &entity.entity_type {
170                Some(t) => SqlValue::Text(t.clone()),
171                None => SqlValue::Null,
172            },
173            SqlValue::Text(entity.name.clone()),
174            match &entity.description {
175                Some(d) => SqlValue::Text(d.clone()),
176                None => SqlValue::Null,
177            },
178            match properties_str {
179                Some(p) => SqlValue::Text(p),
180                None => SqlValue::Null,
181            },
182            SqlValue::Text(tags_str),
183            SqlValue::Integer(entity.updated_at),
184            match entity.deleted_at {
185                Some(d) => SqlValue::Integer(d),
186                None => SqlValue::Null,
187            },
188            match entity.merged_into {
189                Some(u) => SqlValue::Text(u.to_string()),
190                None => SqlValue::Null,
191            },
192            match entity.merge_event_id {
193                Some(u) => SqlValue::Text(u.to_string()),
194                None => SqlValue::Null,
195            },
196            SqlValue::Text(entity.id.to_string()),
197            SqlValue::Integer(expected_updated_at),
198            match expected_deleted_at {
199                Some(value) => SqlValue::Integer(value),
200                None => SqlValue::Null,
201            },
202            SqlValue::Integer(entity.version),
203        ],
204        label: Some("entity-replace-if-unchanged".to_string()),
205    }
206}
207
208/// The exact soft-delete `UPDATE` this store's `delete_entity(Soft)` issues.
209pub fn entity_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
210    SqlStatement {
211        sql: "UPDATE entities SET deleted_at = ?1, version = version + 1 WHERE id = ?2 AND deleted_at IS NULL".to_string(),
212        params: vec![
213            SqlValue::Integer(deleted_at),
214            SqlValue::Text(id.to_string()),
215        ],
216        label: Some("entity-delete-soft".to_string()),
217    }
218}
219
220/// Preserve merge provenance when tombstoning a live source in its existing namespace.
221pub fn entity_merge_tombstone_statement(
222    namespace: &str,
223    from_id: Uuid,
224    into_id: Uuid,
225    merge_event_id: Uuid,
226    now: i64,
227) -> SqlStatement {
228    SqlStatement::new(
229        "UPDATE entities \
230             SET deleted_at = ?1, merged_into = ?2, merge_event_id = ?3, updated_at = ?1, version = version + 1 \
231             WHERE namespace = ?4 AND id = ?5 AND deleted_at IS NULL",
232        vec![
233            SqlValue::Integer(now),
234            SqlValue::Text(into_id.to_string()),
235            SqlValue::Text(merge_event_id.to_string()),
236            SqlValue::Text(namespace.to_string()),
237            SqlValue::Text(from_id.to_string()),
238        ],
239    )
240    .labelled("entity-merge-tombstone")
241}
242
243/// The exact hard-delete `DELETE` this store's `delete_entity(Hard)` issues
244/// (no `deleted_at` predicate — purges live and already-tombstoned rows).
245pub fn entity_hard_delete_statement(id: Uuid) -> SqlStatement {
246    SqlStatement {
247        sql: "DELETE FROM entities WHERE id = ?1".to_string(),
248        params: vec![SqlValue::Text(id.to_string())],
249        label: Some("entity-delete-hard".to_string()),
250    }
251}
252
253/// An EntityStore backed by SQLite. Namespace is the caller's responsibility.
254///
255/// UUID is globally unique — get/delete by ID alone. Query/count use the
256/// namespace parameter as passed. Read routing is always pool-backed; the
257/// constructor's legacy file-backed flag is retained for API compatibility.
258pub struct SqlEntityStore {
259    pool: Arc<ConnectionPool>,
260    writer_task: Option<WriterTaskHandle>,
261}
262
263#[derive(Clone, Copy, PartialEq, Eq)]
264enum EntityPageMode {
265    ExactTotal,
266    CountFree,
267}
268
269impl EntityPageMode {
270    fn operation(self) -> &'static str {
271        match self {
272            Self::ExactTotal => "query_entities",
273            Self::CountFree => "query_entities_count_free",
274        }
275    }
276}
277
278impl SqlEntityStore {
279    /// Create a new store.
280    ///
281    /// When `KHIVE_WRITE_QUEUE=1` (`PoolConfig::write_queue_enabled`), every
282    /// write path on this store — the batch `upsert_entities` (its own
283    /// explicit flag check) AND every single-row write routed through the
284    /// shared `with_writer` helper (`upsert_entity`, `delete_entity`) —
285    /// routes through the pool-wide `WriterTask`
286    /// (`ConnectionPool::writer_task_handle`) instead of the legacy
287    /// pool-mutex path. The handle is a clone of the ONE writer task owned
288    /// by `pool` — constructing multiple stores (or multiple namespaces)
289    /// over the same pool never spawns more than one writer task; see
290    /// `ConnectionPool::writer_task_handle`'s doc comment for why that
291    /// matters. `None` (falling back to the legacy path for every write)
292    /// if the resolved flag is off, or if the writer task failed to spawn
293    /// (for example, an in-memory pool, which has no standalone-connection
294    /// support) — enabled by default for file-backed pools; explicit
295    /// off/degraded fallback remains possible.
296    pub fn new(pool: Arc<ConnectionPool>, _is_file_backed: bool) -> Self {
297        // Enabled by default for file-backed pools; explicit off/degraded
298        // fallback remains possible: a missing writer task — whether
299        // explicitly disabled, spawn degraded (e.g. in-memory pool), or no
300        // Tokio runtime was available at this first access (ADR-067
301        // Component A runtime-handle guard) — is cached without failing
302        // construction. Every write re-resolves it; strict mode refuses a
303        // remaining miss and compatibility mode may use the legacy path.
304        let writer_task = pool.writer_task_handle().ok().flatten();
305
306        Self { pool, writer_task }
307    }
308
309    fn current_writer_task(
310        &self,
311        operation: &'static str,
312    ) -> Result<Option<WriterTaskHandle>, StorageError> {
313        self.pool
314            .writer_task_for_write(self.writer_task.as_ref(), operation)
315    }
316
317    /// Route a single-row write through the pool-wide `WriterTask` when
318    /// the write queue is enabled and a handle is available. Strict mode
319    /// refuses a missing handle; compatibility mode falls back to the legacy
320    /// pool-mutex path.
321    ///
322    /// ADR-067 Component A (Fork C slice 2): this is the ONE routing point
323    /// for every `with_writer` caller in this store — `upsert_entity`,
324    /// `delete_entity` (soft/hard) all reach the WriterTask through this
325    /// helper rather than each duplicating the flag check. `f` must be
326    /// DML-only (a single statement, no bare `BEGIN IMMEDIATE`): on the
327    /// flag-on path it runs inside the WriterTask's own transaction, and a
328    /// nested `BEGIN IMMEDIATE` would violate SQLite's nested-transaction
329    /// rule. `upsert_entities` (the batch method) performs the same write-time
330    /// lookup first; a non-strict `None` then falls through this helper, which
331    /// records the actual compatibility fallback. Strict mode returns before
332    /// either direct-writer seam is reached.
333    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
334    where
335        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
336        R: Send + 'static,
337    {
338        if let Some(writer_task) = self.current_writer_task(op)? {
339            return writer_task
340                .send_bounded_at(move |conn| f(conn).map_err(|e| map_err(e, op)), op)
341                .await;
342        }
343
344        self.pool
345            .record_direct_route(crate::timeout_sink::Site::DirectRouteEntity);
346        let pool = Arc::clone(&self.pool);
347        tokio::task::spawn_blocking(move || {
348            let guard = pool
349                .autocommit_write_unit()
350                .map_err(|e| map_sqlite_err(e, op))?;
351            f(guard.conn())
352                .map_err(|e| map_err(e, op))
353                .map_err(crate::error::statement_failure)
354                .inspect_err(|error| pool.record_direct_writer_error(error))
355        })
356        .await
357        .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
358    }
359
360    /// Route multi-statement entity mutations through one write transaction.
361    /// The writer task already supplies that transaction; the direct fallback
362    /// opens and closes its own fail-closed `BEGIN IMMEDIATE` unit.
363    async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
364    where
365        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
366        R: Send + 'static,
367    {
368        if let Some(writer_task) = self.current_writer_task(op)? {
369            return writer_task
370                .send_bounded_at(move |conn| f(conn).map_err(|error| map_err(error, op)), op)
371                .await;
372        }
373
374        self.pool
375            .record_direct_route(crate::timeout_sink::Site::DirectRouteEntity);
376        let pool = Arc::clone(&self.pool);
377        tokio::task::spawn_blocking(move || {
378            let guard = pool
379                .transaction_write_unit()
380                .map_err(|error| map_sqlite_err(error, op))
381                .inspect_err(|error| pool.record_direct_writer_error(error))?;
382            let conn = guard.conn();
383            let (result, terminal_state) = execute_wrapped_transaction(conn, op, move |conn| {
384                f(conn).map_err(|error| map_err(error, op))
385            });
386            if terminal_state.is_some() {
387                pool.retire_pooled_writer(conn);
388            }
389            result.inspect_err(|error| pool.record_direct_writer_error(error))
390        })
391        .await
392        .map_err(|error| StorageError::driver(StorageCapability::Entities, op, error))?
393    }
394
395    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
396    where
397        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
398        R: Send + 'static,
399    {
400        super::run_pooled_store_read(
401            Arc::clone(&self.pool),
402            StorageCapability::Entities,
403            op,
404            move |conn| f(conn).map_err(|error| map_err(error, op)),
405        )
406        .await
407    }
408
409    async fn with_list_reader<F, R>(
410        &self,
411        op: &'static str,
412        index: Option<&'static str>,
413        mut read: F,
414    ) -> Result<R, StorageError>
415    where
416        F: FnMut(&rusqlite::Connection, bool) -> Result<R, rusqlite::Error> + Send + 'static,
417        R: Send + 'static,
418    {
419        let dispatch = tracing::dispatcher::get_default(Clone::clone);
420        self.with_reader(op, move |conn| match read(conn, true) {
421            Ok(value) => Ok(value),
422            Err(error) => {
423                let Some(index) = index.filter(|index| missing_list_index(&error, index)) else {
424                    return Err(error);
425                };
426                tracing::dispatcher::with_default(&dispatch, || {
427                    tracing::warn!(
428                        index,
429                        operation = op,
430                        "entity list index missing; retrying without forced index"
431                    );
432                });
433                read(conn, false)
434            }
435        })
436        .await
437    }
438
439    async fn query_entities_page(
440        &self,
441        namespace: &str,
442        filter: EntityFilter,
443        page: PageRequest,
444        mode: EntityPageMode,
445    ) -> Result<Page<Entity>, StorageError> {
446        let operation = mode.operation();
447        super::validate_json_equality_paths(
448            &filter.property_equalities,
449            StorageCapability::Entities,
450            operation,
451        )?;
452        let namespace = namespace.to_string();
453        let skip_total = mode == EntityPageMode::CountFree || is_complete_id_lookup(&filter, &page);
454        let limit_i64 = i64::from(page.limit);
455        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
456            capability: StorageCapability::Entities,
457            operation: operation.into(),
458            message: format!(
459                "PageRequest: offset must be <= i64::MAX, got {}",
460                page.offset
461            ),
462        })?;
463
464        let streaming = mode == EntityPageMode::CountFree && is_entity_streaming_page(&filter);
465        let index = if streaming {
466            entity_count_free_index(&filter)
467        } else {
468            entity_list_index(&filter)
469        };
470        let read = move |conn: &rusqlite::Connection, force_index| {
471            let total = if filter.names_ci.is_empty() && !skip_total {
472                let (count_sql, count_params) = build_entity_where(&namespace, &filter);
473                let sql = entity_list_query(
474                    &filter,
475                    build_entity_count_query(&filter, &count_sql),
476                    force_index,
477                );
478                let mut stmt = conn.prepare(&sql)?;
479                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
480                    count_params.iter().map(|p| p.as_ref()).collect();
481                Some(stmt.query_row(param_refs.as_slice(), |row| row.get::<_, i64>(0))? as u64)
482            } else {
483                None
484            };
485
486            let mut lookup_filter = filter.clone();
487            lookup_filter.names_ci.clear();
488            let effective_filter = if filter.names_ci.is_empty() {
489                &filter
490            } else {
491                &lookup_filter
492            };
493            let (where_sql, mut data_params) = if streaming {
494                build_entity_streaming_where(&namespace, effective_filter)
495            } else {
496                build_entity_where(&namespace, effective_filter)
497            };
498
499            let candidate_param_indices = if filter.names_ci.is_empty() {
500                Vec::new()
501            } else {
502                let mut candidates: Vec<String> = filter
503                    .names_ci
504                    .iter()
505                    .map(|name| name.to_ascii_lowercase())
506                    .collect();
507                candidates.sort_unstable();
508                candidates.dedup();
509                candidates
510                    .into_iter()
511                    .map(|candidate| {
512                        data_params.push(Box::new(candidate));
513                        data_params.len()
514                    })
515                    .collect()
516            };
517
518            // #818: when a name_prefix filter is active, an exact
519            // ASCII-case-insensitive match must never be pushed out of the page by
520            // pattern candidates that merely share the prefix. Rank exact
521            // matches first (deterministic tiebreak via created_at) so page
522            // truncation can never hide the record a caller resolved by name.
523            let order_by = if let Some(ref prefix) = filter.name_prefix {
524                data_params.push(Box::new(prefix.to_ascii_lowercase()));
525                format!(
526                    "CASE WHEN LOWER(name) = ?{} THEN 0 ELSE 1 END, created_at DESC, id DESC",
527                    data_params.len()
528                )
529            } else {
530                // #1671: append `id` as the final tiebreak in the primary
531                // key's direction so equal-`created_at` rows keep a fixed
532                // order across page boundaries. The deterministic total order
533                // removes tie-order instability only — offset paging can still
534                // duplicate or skip rows under concurrent inserts/deletes or
535                // sort-key updates (that would need snapshot isolation or
536                // keyset pagination).
537                "created_at DESC, id DESC".to_string()
538            };
539
540            data_params.push(Box::new(limit_i64));
541            data_params.push(Box::new(offset_i64));
542
543            let limit_idx = data_params.len() - 1;
544            let offset_idx = data_params.len();
545
546            let columns = ENTITY_SELECT_COLUMNS;
547            let data_sql = if streaming {
548                build_entity_count_free_page_query(
549                    columns,
550                    effective_filter,
551                    &where_sql,
552                    &order_by,
553                    limit_idx,
554                    offset_idx,
555                )
556            } else if filter.names_ci.is_empty() {
557                build_entity_page_query(
558                    columns,
559                    effective_filter,
560                    &where_sql,
561                    &order_by,
562                    limit_idx,
563                    offset_idx,
564                )
565            } else {
566                build_candidate_entity_query(
567                    columns,
568                    effective_filter,
569                    &where_sql,
570                    &candidate_param_indices,
571                    &order_by,
572                    limit_idx,
573                    offset_idx,
574                )
575            };
576
577            let data_sql = if streaming {
578                entity_count_free_query(effective_filter, data_sql, force_index)
579            } else {
580                entity_list_query(effective_filter, data_sql, force_index)
581            };
582            let mut stmt = conn.prepare(&data_sql)?;
583            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
584                data_params.iter().map(|p| p.as_ref()).collect();
585            let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
586
587            let mut items = Vec::new();
588            for row in rows {
589                items.push(row?);
590            }
591
592            Ok(Page { items, total })
593        };
594        self.with_list_reader(operation, index, read).await
595    }
596}
597
598// =============================================================================
599// Helpers
600// =============================================================================
601
602fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
603    let id_str: String = row.get(0)?;
604    let namespace: String = row.get(1)?;
605    let kind: String = row.get(2)?;
606    let entity_type: Option<String> = row.get(3)?;
607    let name: String = row.get(4)?;
608    let description: Option<String> = row.get(5)?;
609    let properties_str: Option<String> = row.get(6)?;
610    let tags_str: String = row.get(7)?;
611    let created_at: i64 = row.get(8)?;
612    let updated_at: i64 = row.get(9)?;
613    let deleted_at: Option<i64> = row.get(10)?;
614    let merged_into_str: Option<String> = row.get(11)?;
615    let merge_event_id_str: Option<String> = row.get(12)?;
616    let content_ref: Option<String> = row.get(13)?;
617    let version: i64 = row.get(14)?;
618
619    let id = parse_uuid(&id_str)?;
620
621    let properties = properties_str
622        .map(|s| {
623            serde_json::from_str(&s).map_err(|e| {
624                rusqlite::Error::FromSqlConversionFailure(
625                    6,
626                    rusqlite::types::Type::Text,
627                    Box::new(e),
628                )
629            })
630        })
631        .transpose()?;
632
633    let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
634        rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
635    })?;
636
637    let merged_into = merged_into_str
638        .as_deref()
639        .map(Uuid::parse_str)
640        .transpose()
641        .map_err(|e| {
642            rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
643        })?;
644
645    let merge_event_id = merge_event_id_str
646        .as_deref()
647        .map(Uuid::parse_str)
648        .transpose()
649        .map_err(|e| {
650            rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
651        })?;
652
653    Ok(Entity {
654        id,
655        namespace,
656        kind,
657        entity_type,
658        name,
659        description,
660        properties,
661        tags,
662        created_at,
663        updated_at,
664        version,
665        deleted_at,
666        merged_into,
667        merge_event_id,
668        content_ref,
669    })
670}
671
672/// DML-only batch upsert loop shared by both the legacy (flag-off) and
673/// WriterTask-routed (flag-on) `upsert_entities` paths (ADR-067 slice 1).
674///
675/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
676/// enclosing transaction. Per-row failures are captured into
677/// `BatchWriteSummary::failed`/`first_error` rather than aborting the loop,
678/// matching the existing partial-success contract: this function's own
679/// `Result` is `Ok` unless a caller bug is present, since no branch here
680/// returns `Err`.
681fn batch_upsert_entities(
682    conn: &rusqlite::Connection,
683    entities: &[Entity],
684    attempted: u64,
685) -> Result<BatchWriteSummary, rusqlite::Error> {
686    let mut summary = BatchWriteSummary {
687        attempted,
688        ..BatchWriteSummary::default()
689    };
690
691    for (index, entity) in entities.iter().enumerate() {
692        let id_str = entity.id.to_string();
693        let statement = entity_upsert_statement(entity);
694        let result = (|| {
695            let mut prepared = conn.prepare(&statement.sql)?;
696            bind_params(&mut prepared, &statement.params)?;
697            prepared.raw_execute()
698        })();
699        match result {
700            Ok(_) => summary.affected = summary.affected.saturating_add(1),
701            Err(e) => {
702                let (class, retryability) = super::classify_batch_sqlite_error(&e);
703                summary.record_failure(index, Some(id_str), class, retryability, e.to_string());
704            }
705        }
706    }
707
708    Ok(summary)
709}
710
711fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
712    Uuid::parse_str(s).map_err(|e| {
713        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
714    })
715}
716
717fn build_entity_where(
718    namespace: &str,
719    filter: &EntityFilter,
720) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
721    build_entity_where_with_mode(namespace, filter, false)
722}
723
724/// Evaluate legacy type membership on the current row instead of first
725/// materializing every matching entity ID. Deduplication also makes each
726/// single-value ordered-index prefix a single SQL bound value.
727fn build_entity_streaming_where(
728    namespace: &str,
729    filter: &EntityFilter,
730) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
731    let mut filter = filter.clone();
732    for values in [
733        &mut filter.namespaces,
734        &mut filter.kinds,
735        &mut filter.entity_types,
736    ] {
737        values.sort_unstable();
738        values.dedup();
739    }
740    for values in filter.entity_types_by_kind.values_mut() {
741        values.sort_unstable();
742        values.dedup();
743    }
744    build_entity_where_with_mode(namespace, &filter, true)
745}
746
747fn build_entity_where_with_mode(
748    namespace: &str,
749    filter: &EntityFilter,
750    row_local: bool,
751) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
752    // When filter.namespaces is non-empty use `namespace IN (...)` so that
753    // multi-namespace read visibility works.  Otherwise fall back to the
754    // single-namespace equality check for backward compatibility.
755    let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
756        if !filter.namespaces.is_empty() {
757            let placeholders: Vec<String> = (1..=filter.namespaces.len())
758                .map(|i| format!("?{i}"))
759                .collect();
760            let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
761                .namespaces
762                .iter()
763                .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
764                .collect();
765            (
766                format!("namespace IN ({})", placeholders.join(", ")),
767                params,
768            )
769        } else {
770            (
771                "namespace = ?1".to_string(),
772                vec![Box::new(namespace.to_string())],
773            )
774        };
775
776    let mut conditions: Vec<String> = vec![ns_condition];
777    match filter.tombstones {
778        EntityTombstones::Live => conditions.push("deleted_at IS NULL".into()),
779        EntityTombstones::All => {}
780        EntityTombstones::Only => conditions.push("deleted_at IS NOT NULL".into()),
781    }
782    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
783
784    if !filter.ids.is_empty() {
785        let placeholders: Vec<String> = filter
786            .ids
787            .iter()
788            .map(|id| {
789                params.push(Box::new(id.to_string()));
790                format!("?{}", params.len())
791            })
792            .collect();
793        conditions.push(format!("id IN ({})", placeholders.join(", ")));
794    }
795
796    if !filter.kinds.is_empty() {
797        let placeholders: Vec<String> = filter
798            .kinds
799            .iter()
800            .map(|k| {
801                params.push(Box::new(k.clone()));
802                format!("?{}", params.len())
803            })
804            .collect();
805        conditions.push(format!("kind IN ({})", placeholders.join(", ")));
806    }
807
808    let type_scope = conditions.join(" AND ");
809    let type_predicate = |scope: &str, placeholders: &str| {
810        if filter.legacy_entity_type_fallback && row_local {
811            // CASE is lazy: malformed JSON never reaches json_type/extract.
812            // A present canonical type always wins over legacy properties.
813            format!(
814                "CASE WHEN entity_type IS NOT NULL THEN entity_type IN ({placeholders}) \
815                 WHEN json_valid(properties) THEN \
816                     CASE WHEN json_type(properties, '$.type') = 'text' \
817                          THEN json_extract(properties, '$.type') IN ({placeholders}) \
818                          ELSE 0 END \
819                 ELSE 0 END"
820            )
821        } else if filter.legacy_entity_type_fallback {
822            // Legacy properties can contain invalid JSON. Exclude those rows
823            // from type fallback without rewriting them. Keep json_valid as
824            // an explicit term matching the partial legacy-type index;
825            // json_type alone does not imply its validity predicate to SQLite.
826            format!(
827                "id IN (SELECT id FROM entities WHERE {scope} \
828                 AND entity_type IN ({placeholders}) \
829                 UNION ALL SELECT id FROM entities WHERE {scope} \
830                 AND entity_type IS NULL AND json_valid(properties) \
831                 AND json_type(properties, '$.type') = 'text' \
832                 AND json_extract(properties, '$.type') IN ({placeholders}))"
833            )
834        } else {
835            format!("entity_type IN ({placeholders})")
836        }
837    };
838    if !filter.entity_types.is_empty() {
839        let placeholders: Vec<String> = filter
840            .entity_types
841            .iter()
842            .map(|t| {
843                params.push(Box::new(t.clone()));
844                format!("?{}", params.len())
845            })
846            .collect();
847        conditions.push(type_predicate(&type_scope, &placeholders.join(", ")));
848    }
849
850    if !filter.entity_types_by_kind.is_empty() {
851        let mut groups = Vec::new();
852        for (kind, types) in &filter.entity_types_by_kind {
853            if types.is_empty() {
854                continue;
855            }
856            params.push(Box::new(kind.clone()));
857            let kind_param = params.len();
858            let placeholders = types
859                .iter()
860                .map(|value| {
861                    params.push(Box::new(value.clone()));
862                    format!("?{}", params.len())
863                })
864                .collect::<Vec<_>>()
865                .join(", ");
866            let scope = format!("{type_scope} AND kind = ?{kind_param}");
867            let predicate = type_predicate(&scope, &placeholders);
868            groups.push(format!("(kind = ?{kind_param} AND {predicate})"));
869        }
870        conditions.push(if groups.is_empty() {
871            "0".to_string()
872        } else {
873            format!("({})", groups.join(" OR "))
874        });
875    }
876
877    if let Some(ref prefix) = filter.name_prefix {
878        params.push(Box::new(format!(
879            "{}%",
880            khive_types::escape_like_literal(prefix)
881        )));
882        conditions.push(format!("name LIKE ?{} ESCAPE '\\'", params.len()));
883    }
884
885    if let Some(ref exact) = filter.name_exact {
886        params.push(Box::new(exact.clone()));
887        // `entities.name` has no `COLLATE NOCASE` (see sql/schema.sql), so
888        // `=` is already SQLite's default case-sensitive BINARY comparison.
889        // `COLLATE BINARY` is spelled out here so this predicate stays
890        // correct even if the column's default collation ever changes.
891        conditions.push(format!("name = ?{} COLLATE BINARY", params.len()));
892    }
893
894    if !filter.names_ci.is_empty() {
895        // ADR-104 Stage C, R1: one batched `LOWER(name) IN (...)` predicate,
896        // served by `idx_entities_namespace_name_ci (namespace, LOWER(name))`.
897        let placeholders: Vec<String> = filter
898            .names_ci
899            .iter()
900            .map(|n| {
901                params.push(Box::new(n.to_ascii_lowercase()));
902                format!("?{}", params.len())
903            })
904            .collect();
905        conditions.push(format!("LOWER(name) IN ({})", placeholders.join(", ")));
906    }
907
908    if !filter.tags_any.is_empty() {
909        let placeholders: Vec<String> = filter
910            .tags_any
911            .iter()
912            .map(|t| {
913                // Normalise to lowercase so the comparison is case-insensitive
914                // domain filter must be case-insensitive.
915                params.push(Box::new(t.to_lowercase()));
916                format!("?{}", params.len())
917            })
918            .collect();
919        conditions.push(format!(
920            "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
921            placeholders.join(", ")
922        ));
923    }
924
925    super::append_json_equalities(
926        &mut conditions,
927        &mut params,
928        "properties",
929        &filter.property_equalities,
930    );
931    let clause = format!(" WHERE {}", conditions.join(" AND "));
932    (clause, params)
933}
934
935/// An ID-filtered read is bounded by the caller's IDs, even on a fresh
936/// database without sqlite_stat1. The other predicates can match a large
937/// namespace; letting one of their indexes drive turns a bounded lookup into
938/// a scan of every row in that namespace.
939fn entity_read_source(filter: &EntityFilter) -> &'static str {
940    if filter.ids.is_empty() {
941        "entities"
942    } else {
943        "entities INDEXED BY sqlite_autoindex_entities_1"
944    }
945}
946
947fn build_entity_count_query(filter: &EntityFilter, where_sql: &str) -> String {
948    let source = entity_list_source(filter);
949    format!("SELECT COUNT(*) FROM {source}{where_sql}")
950}
951
952fn build_entity_page_query(
953    columns: &str,
954    filter: &EntityFilter,
955    where_sql: &str,
956    order_by: &str,
957    limit_idx: usize,
958    offset_idx: usize,
959) -> String {
960    let source = entity_list_source(filter);
961    build_entity_page_query_from_source(columns, source, where_sql, order_by, limit_idx, offset_idx)
962}
963
964fn build_entity_count_free_page_query(
965    columns: &str,
966    filter: &EntityFilter,
967    where_sql: &str,
968    order_by: &str,
969    limit_idx: usize,
970    offset_idx: usize,
971) -> String {
972    let source = entity_count_free_source(filter);
973    build_entity_page_query_from_source(columns, source, where_sql, order_by, limit_idx, offset_idx)
974}
975
976fn build_entity_page_query_from_source(
977    columns: &str,
978    source: &str,
979    where_sql: &str,
980    order_by: &str,
981    limit_idx: usize,
982    offset_idx: usize,
983) -> String {
984    format!(
985        "SELECT {columns} FROM {source}{where_sql} \
986         ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
987    )
988}
989
990/// Match only ordinary list shapes whose leading predicates fit these indexes.
991/// Keep selective names/kinds/tags and legacy JSON paths free to choose their
992/// own access paths. Explicit IDs always keep the bounded primary-key plan.
993fn entity_list_source(filter: &EntityFilter) -> &'static str {
994    if filter.tombstones != EntityTombstones::Live
995        || !filter.property_equalities.is_empty()
996        || !filter.ids.is_empty()
997        || !filter.kinds.is_empty()
998        || !filter.entity_types_by_kind.is_empty()
999        || filter.legacy_entity_type_fallback
1000        || filter.name_prefix.is_some()
1001        || filter.name_exact.is_some()
1002        || !filter.names_ci.is_empty()
1003        || !filter.tags_any.is_empty()
1004    {
1005        return entity_read_source(filter);
1006    }
1007    if !filter.entity_types.is_empty() {
1008        "entities INDEXED BY idx_entities_live_namespace_type_order"
1009    } else if filter.namespaces.len() <= 1 {
1010        "entities INDEXED BY idx_entities_live_namespace_order"
1011    } else {
1012        "entities"
1013    }
1014}
1015
1016fn entity_list_index(filter: &EntityFilter) -> Option<&'static str> {
1017    if !filter.ids.is_empty() {
1018        return None;
1019    }
1020    entity_list_source(filter).strip_prefix("entities INDEXED BY ")
1021}
1022
1023fn is_entity_streaming_page(filter: &EntityFilter) -> bool {
1024    filter.ids.is_empty()
1025        && filter.name_prefix.is_none()
1026        && filter.name_exact.is_none()
1027        && filter.names_ci.is_empty()
1028}
1029
1030fn has_one_distinct_value(values: &[String]) -> bool {
1031    values
1032        .first()
1033        .is_some_and(|first| values.iter().all(|value| value == first))
1034}
1035
1036/// Pin creation-order walks only when every leading index column is fixed.
1037/// Other predicates remain row-local residual filters, so the page can stop
1038/// after its limit without materializing the complete matching ID set.
1039fn entity_count_free_source(filter: &EntityFilter) -> &'static str {
1040    if filter.tombstones != EntityTombstones::Live {
1041        return entity_read_source(filter);
1042    }
1043    if !is_entity_streaming_page(filter) {
1044        return entity_list_source(filter);
1045    }
1046    if !filter.namespaces.is_empty() && !has_one_distinct_value(&filter.namespaces) {
1047        return "entities";
1048    }
1049    // A grouped-type map whose groups are all empty matches nothing and becomes
1050    // a constant-false predicate. SQLite folds it into the WHERE clause and can
1051    // then no longer prove the `deleted_at IS NULL` term of a partial order
1052    // index, so forcing one fails with "no query solution".
1053    if !filter.entity_types_by_kind.is_empty()
1054        && filter.entity_types_by_kind.values().all(Vec::is_empty)
1055    {
1056        return "entities";
1057    }
1058
1059    let mut kind_groups = filter
1060        .entity_types_by_kind
1061        .iter()
1062        .filter(|(_, types)| !types.is_empty());
1063    let one_kind_group = kind_groups.next().is_some() && kind_groups.next().is_none();
1064    if has_one_distinct_value(&filter.kinds) || one_kind_group {
1065        "entities INDEXED BY idx_entities_live_namespace_kind_order"
1066    } else if !filter.legacy_entity_type_fallback && has_one_distinct_value(&filter.entity_types) {
1067        // Multiple distinct types give multiple ordered runs, not one global
1068        // creation order. Use the namespace index for that residual predicate.
1069        "entities INDEXED BY idx_entities_live_namespace_type_order"
1070    } else {
1071        "entities INDEXED BY idx_entities_live_namespace_order"
1072    }
1073}
1074
1075fn entity_count_free_index(filter: &EntityFilter) -> Option<&'static str> {
1076    if !filter.ids.is_empty() {
1077        return None;
1078    }
1079    entity_count_free_source(filter).strip_prefix("entities INDEXED BY ")
1080}
1081
1082fn missing_list_index(error: &rusqlite::Error, index: &str) -> bool {
1083    if error
1084        .sqlite_error()
1085        .is_none_or(|detail| detail.extended_code != rusqlite::ffi::SQLITE_ERROR)
1086    {
1087        return false;
1088    }
1089    let message = match error {
1090        rusqlite::Error::SqliteFailure(_, Some(message)) => message.as_str(),
1091        rusqlite::Error::SqlInputError { msg, .. } => msg.as_str(),
1092        _ => return false,
1093    };
1094    message.strip_prefix("no such index: ") == Some(index)
1095}
1096
1097fn entity_list_query(filter: &EntityFilter, sql: String, force_index: bool) -> String {
1098    entity_query_with_index(
1099        sql,
1100        entity_list_source(filter),
1101        entity_list_index(filter),
1102        force_index,
1103    )
1104}
1105
1106fn entity_count_free_query(filter: &EntityFilter, sql: String, force_index: bool) -> String {
1107    entity_query_with_index(
1108        sql,
1109        entity_count_free_source(filter),
1110        entity_count_free_index(filter),
1111        force_index,
1112    )
1113}
1114
1115fn entity_query_with_index(
1116    sql: String,
1117    source: &str,
1118    index: Option<&str>,
1119    force_index: bool,
1120) -> String {
1121    if force_index || index.is_none() {
1122        sql
1123    } else {
1124        sql.replacen(source, "entities", 1)
1125    }
1126}
1127
1128fn build_candidate_entity_query(
1129    columns: &str,
1130    filter: &EntityFilter,
1131    where_sql: &str,
1132    candidate_param_indices: &[usize],
1133    order_by: &str,
1134    limit_idx: usize,
1135    offset_idx: usize,
1136) -> String {
1137    let source = entity_read_source(filter);
1138    let candidate_rows = candidate_param_indices
1139        .iter()
1140        .map(|idx| format!("(?{idx})"))
1141        .collect::<Vec<_>>()
1142        .join(", ");
1143
1144    format!(
1145        "WITH candidates(folded_name) AS (VALUES {candidate_rows}), \
1146         matched_entities(entity_id) AS (\
1147             SELECT (\
1148                 SELECT id FROM {source}{where_sql} \
1149                 AND LOWER(name) = candidates.folded_name LIMIT 1\
1150             ) FROM candidates\
1151         ) \
1152         SELECT {columns} FROM entities \
1153         JOIN matched_entities ON entities.id = matched_entities.entity_id \
1154         ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
1155    )
1156}
1157
1158fn build_entity_cursor_query(
1159    columns: &str,
1160    filter: &EntityFilter,
1161    where_sql: &str,
1162    limit_idx: usize,
1163) -> String {
1164    // CROSS JOIN fixes the loop order. An explicit ID set drives entities by
1165    // primary key, then looks up each sequence. Every non-ID walk drives
1166    // the sequence range first and checks the entity through its primary key;
1167    // kind/type predicates must not force a full matching-set sort.
1168    let source = entity_read_source(filter);
1169    let from_clause = if !filter.ids.is_empty() {
1170        format!("{source} CROSS JOIN entities_seq ON entities.id = entities_seq.entity_id")
1171    } else {
1172        "entities_seq CROSS JOIN entities INDEXED BY sqlite_autoindex_entities_1 \
1173         ON entities.id = entities_seq.entity_id"
1174            .to_string()
1175    };
1176    format!(
1177        "SELECT {columns}, entities_seq.seq FROM {from_clause}{where_sql} \
1178         ORDER BY entities_seq.seq ASC LIMIT ?{limit_idx}"
1179    )
1180}
1181
1182fn is_complete_id_lookup(filter: &EntityFilter, page: &PageRequest) -> bool {
1183    !filter.ids.is_empty()
1184        && filter.property_equalities.is_empty()
1185        && filter.kinds.is_empty()
1186        && filter.entity_types.is_empty()
1187        && filter.entity_types_by_kind.is_empty()
1188        && filter.name_prefix.is_none()
1189        && filter.name_exact.is_none()
1190        && filter.tags_any.is_empty()
1191        && filter.names_ci.is_empty()
1192        && page.offset == 0
1193        && usize::try_from(page.limit).ok() == Some(filter.ids.len())
1194}
1195
1196// =============================================================================
1197// EntityStore implementation
1198// =============================================================================
1199
1200#[async_trait]
1201impl EntityStore for SqlEntityStore {
1202    async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
1203        let statement = entity_upsert_statement(&entity);
1204        self.with_writer("upsert_entity", move |conn| {
1205            let mut stmt = conn.prepare(&statement.sql)?;
1206            bind_params(&mut stmt, &statement.params)?;
1207            stmt.raw_execute()?;
1208            Ok(())
1209        })
1210        .await
1211    }
1212
1213    async fn insert_entity_if_absent(&self, entity: Entity) -> Result<bool, StorageError> {
1214        let statement = entity_insert_if_absent_statement(&entity);
1215        self.with_writer("insert_entity_if_absent", move |conn| {
1216            let mut stmt = conn.prepare(&statement.sql)?;
1217            bind_params(&mut stmt, &statement.params)?;
1218            Ok(stmt.raw_execute()? > 0)
1219        })
1220        .await
1221    }
1222
1223    async fn upsert_entity_with_attachments(
1224        &self,
1225        entity: Entity,
1226        attachments: Vec<Attachment>,
1227    ) -> Result<(), StorageError> {
1228        let entity_id = entity.id;
1229        let entity_statement = entity_upsert_statement(&entity);
1230        let mut attachment_statements = Vec::with_capacity(attachments.len());
1231        for attachment in attachments {
1232            attachment.validate()?;
1233            if attachment.record_uuid != entity_id
1234                || attachment.substrate != AttachmentSubstrate::Entity
1235            {
1236                return Err(StorageError::InvalidInput {
1237                    capability: StorageCapability::Attachments,
1238                    operation: "upsert_entity_with_attachments".into(),
1239                    message: format!(
1240                        "attachment {} must target entity {entity_id}",
1241                        attachment.role
1242                    ),
1243                });
1244            }
1245            attachment_statements.push(attachment_upsert_statement(&attachment)?);
1246        }
1247
1248        self.with_writer_tx("upsert_entity_with_attachments", move |conn| {
1249            let mut entity_stmt = conn.prepare(&entity_statement.sql)?;
1250            bind_params(&mut entity_stmt, &entity_statement.params)?;
1251            entity_stmt.raw_execute()?;
1252            drop(entity_stmt);
1253
1254            for statement in attachment_statements {
1255                let mut stmt = conn.prepare(&statement.sql)?;
1256                bind_params(&mut stmt, &statement.params)?;
1257                stmt.raw_execute()?;
1258            }
1259            Ok(())
1260        })
1261        .await
1262    }
1263
1264    async fn upsert_entities(
1265        &self,
1266        entities: Vec<Entity>,
1267    ) -> Result<BatchWriteSummary, StorageError> {
1268        let attempted = entities.len() as u64;
1269
1270        // ADR-067 slice 1: when the write queue is enabled, route through
1271        // the WriterTask channel. The closure is DML-only — no BEGIN
1272        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
1273        // owns the transaction and `WriteRequest::execute_and_reply` owns
1274        // the commit/rollback decision (a bare BEGIN IMMEDIATE inside this
1275        // closure would violate SQLite's nested-transaction rule).
1276        if let Some(writer_task) = self.current_writer_task("upsert_entities")? {
1277            return writer_task
1278                .send_bounded_at(
1279                    move |conn| {
1280                        batch_upsert_entities(conn, &entities, attempted)
1281                            .map_err(|e| map_err(e, "upsert_entities"))
1282                    },
1283                    "upsert_entities",
1284                )
1285                .await;
1286        }
1287
1288        // Explicitly disabled or degraded fallback path: byte-for-byte unchanged from pre-ADR-067
1289        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT/ROLLBACK
1290        // via the pool-mutex writer.
1291        let origin = self.pool.origin();
1292        self.with_writer("upsert_entities", move |conn| {
1293            conn.execute_batch("BEGIN IMMEDIATE")?;
1294            let _tx_handle = khive_storage::tx_registry::register_scoped(
1295                Some("entity_upsert_batch".to_string()),
1296                origin,
1297            );
1298
1299            let summary = batch_upsert_entities(conn, &entities, attempted)?;
1300
1301            if let Err(e) = conn.execute_batch("COMMIT") {
1302                let _ = conn.execute_batch("ROLLBACK");
1303                return Err(e);
1304            }
1305            Ok(summary)
1306        })
1307        .await
1308    }
1309
1310    async fn replace_entity_if_unchanged(
1311        &self,
1312        entity: Entity,
1313        expected_updated_at: i64,
1314        expected_deleted_at: Option<i64>,
1315    ) -> Result<bool, StorageError> {
1316        let statement = entity_replace_if_unchanged_statement(
1317            &entity,
1318            expected_updated_at,
1319            expected_deleted_at,
1320        );
1321        self.with_writer("replace_entity_if_unchanged", move |conn| {
1322            let mut stmt = conn.prepare(&statement.sql)?;
1323            bind_params(&mut stmt, &statement.params)?;
1324            Ok(stmt.raw_execute()? > 0)
1325        })
1326        .await
1327    }
1328
1329    async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
1330        let id_str = id.to_string();
1331
1332        self.with_reader("get_entity", move |conn| {
1333            let sql = format!(
1334                "SELECT {ENTITY_SELECT_COLUMNS} FROM entities \
1335                 WHERE entities.id = ?1 AND entities.deleted_at IS NULL"
1336            );
1337            let mut stmt = conn.prepare(&sql)?;
1338            let mut rows = stmt.query(rusqlite::params![id_str])?;
1339            match rows.next()? {
1340                Some(row) => Ok(Some(read_entity(row)?)),
1341                None => Ok(None),
1342            }
1343        })
1344        .await
1345    }
1346
1347    async fn entity_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
1348        let id = id.to_string();
1349        self.with_reader("entity_sequence", move |conn| {
1350            conn.query_row(
1351                "SELECT seq FROM entities_seq WHERE entity_id = ?1",
1352                rusqlite::params![id],
1353                |row| row.get(0),
1354            )
1355            .optional()
1356        })
1357        .await
1358    }
1359
1360    async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
1361        match mode {
1362            DeleteMode::Soft => {
1363                let now = chrono::Utc::now().timestamp_micros();
1364                let statement = entity_soft_delete_statement(id, now);
1365                self.with_writer("delete_entity_soft", move |conn| {
1366                    let mut stmt = conn.prepare(&statement.sql)?;
1367                    bind_params(&mut stmt, &statement.params)?;
1368                    Ok(stmt.raw_execute()? > 0)
1369                })
1370                .await
1371            }
1372            DeleteMode::Hard => {
1373                let entity_statement = entity_hard_delete_statement(id);
1374                let attachment_statement =
1375                    delete_record_attachments_statement(id, AttachmentSubstrate::Entity);
1376                self.with_writer_tx("delete_entity_hard", move |conn| {
1377                    let mut entity_stmt = conn.prepare(&entity_statement.sql)?;
1378                    bind_params(&mut entity_stmt, &entity_statement.params)?;
1379                    let deleted = entity_stmt.raw_execute()? > 0;
1380                    drop(entity_stmt);
1381                    if deleted {
1382                        let mut attachment_stmt = conn.prepare(&attachment_statement.sql)?;
1383                        bind_params(&mut attachment_stmt, &attachment_statement.params)?;
1384                        attachment_stmt.raw_execute()?;
1385                    }
1386                    Ok(deleted)
1387                })
1388                .await
1389            }
1390        }
1391    }
1392
1393    async fn query_entities(
1394        &self,
1395        namespace: &str,
1396        filter: EntityFilter,
1397        page: PageRequest,
1398    ) -> Result<Page<Entity>, StorageError> {
1399        self.query_entities_page(namespace, filter, page, EntityPageMode::ExactTotal)
1400            .await
1401    }
1402
1403    async fn query_entities_count_free(
1404        &self,
1405        namespace: &str,
1406        filter: EntityFilter,
1407        page: PageRequest,
1408    ) -> Result<Page<Entity>, StorageError> {
1409        self.query_entities_page(namespace, filter, page, EntityPageMode::CountFree)
1410            .await
1411    }
1412
1413    async fn query_entities_after(
1414        &self,
1415        namespace: &str,
1416        filter: EntityFilter,
1417        after: Option<SeekCursor>,
1418        limit: u32,
1419    ) -> Result<SeekPage<Entity>, StorageError> {
1420        super::validate_json_equality_paths(
1421            &filter.property_equalities,
1422            StorageCapability::Entities,
1423            "query_entities_after",
1424        )?;
1425        if limit == 0 {
1426            return Ok(SeekPage::default());
1427        }
1428        if !filter.names_ci.is_empty() {
1429            return Err(StorageError::InvalidInput {
1430                capability: StorageCapability::Entities,
1431                operation: "query_entities_after".into(),
1432                message: "names_ci candidate folding is not compatible with seek pagination".into(),
1433            });
1434        }
1435
1436        let namespace = namespace.to_string();
1437        let limit_usize = limit as usize;
1438        let probe_limit_i64 = i64::from(limit) + 1;
1439        self.with_reader("query_entities_after", move |conn| {
1440            let (mut where_sql, mut params) = if filter.ids.is_empty() {
1441                build_entity_streaming_where(&namespace, &filter)
1442            } else {
1443                build_entity_where(&namespace, &filter)
1444            };
1445            if let Some(cursor) = after {
1446                params.push(Box::new(cursor.sequence));
1447                where_sql.push_str(&format!(" AND entities_seq.seq > ?{}", params.len()));
1448            }
1449            params.push(Box::new(probe_limit_i64));
1450            let limit_idx = params.len();
1451
1452            let columns = ENTITY_SELECT_COLUMNS;
1453            let sql = build_entity_cursor_query(columns, &filter, &where_sql, limit_idx);
1454            let mut stmt = conn.prepare(&sql)?;
1455            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1456                params.iter().map(|param| param.as_ref()).collect();
1457            let rows = stmt.query_map(param_refs.as_slice(), |row| {
1458                Ok((read_entity(row)?, row.get::<_, i64>(15)?))
1459            })?;
1460            let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
1461            let has_more = entries.len() > limit_usize;
1462            if has_more {
1463                entries.truncate(limit_usize);
1464            }
1465            let next_after = if has_more {
1466                entries.last().map(|(entity, sequence)| SeekCursor {
1467                    sequence: *sequence,
1468                    id: entity.id,
1469                })
1470            } else {
1471                None
1472            };
1473            let items = entries.into_iter().map(|(entity, _)| entity).collect();
1474            Ok(SeekPage { items, next_after })
1475        })
1476        .await
1477    }
1478
1479    async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
1480        let id_str = id.to_string();
1481
1482        self.with_reader("get_entity_including_deleted", move |conn| {
1483            let sql =
1484                format!("SELECT {ENTITY_SELECT_COLUMNS} FROM entities WHERE entities.id = ?1");
1485            let mut stmt = conn.prepare(&sql)?;
1486            let mut rows = stmt.query(rusqlite::params![id_str])?;
1487            match rows.next()? {
1488                Some(row) => Ok(Some(read_entity(row)?)),
1489                None => Ok(None),
1490            }
1491        })
1492        .await
1493    }
1494
1495    async fn count_entities_by_type(
1496        &self,
1497        namespaces: &[String],
1498    ) -> Result<Option<EntityTypeCounts>, StorageError> {
1499        let namespaces =
1500            serde_json::to_string(namespaces).map_err(|error| StorageError::Serialization {
1501                capability: StorageCapability::Entities,
1502                message: error.to_string(),
1503            })?;
1504        self.with_reader("count_entities_by_type", move |conn| {
1505            let mut statement = conn.prepare(ENTITIES_COUNT_BY_TYPE_SQL)?;
1506            let rows = statement.query_map(rusqlite::params![namespaces], |row| {
1507                let count: i64 = row.get(1)?;
1508                let count = u64::try_from(count).map_err(|error| {
1509                    rusqlite::Error::FromSqlConversionFailure(
1510                        1,
1511                        rusqlite::types::Type::Integer,
1512                        Box::new(error),
1513                    )
1514                })?;
1515                Ok((row.get::<_, Option<String>>(0)?, count))
1516            })?;
1517            rows.collect::<Result<Vec<_>, _>>().map(Some)
1518        })
1519        .await
1520    }
1521
1522    async fn count_entities(
1523        &self,
1524        namespace: &str,
1525        filter: EntityFilter,
1526    ) -> Result<u64, StorageError> {
1527        super::validate_json_equality_paths(
1528            &filter.property_equalities,
1529            StorageCapability::Entities,
1530            "count_entities",
1531        )?;
1532        let namespace = namespace.to_string();
1533
1534        let index = entity_list_index(&filter);
1535        self.with_list_reader("count_entities", index, move |conn, force_index| {
1536            if filter.namespaces.is_empty() {
1537                let (where_sql, params) = build_entity_where(&namespace, &filter);
1538                let sql = entity_list_query(
1539                    &filter,
1540                    build_entity_count_query(&filter, &where_sql),
1541                    force_index,
1542                );
1543                let mut stmt = conn.prepare(&sql)?;
1544                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1545                    params.iter().map(|p| p.as_ref()).collect();
1546                let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1547                return Ok(count as u64);
1548            }
1549
1550            let deduped_namespaces: Vec<String> = filter
1551                .namespaces
1552                .iter()
1553                .cloned()
1554                .collect::<HashSet<_>>()
1555                .into_iter()
1556                .collect();
1557
1558            let mut total = 0;
1559            for chunk in deduped_namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1560                let chunk_filter = EntityFilter {
1561                    namespaces: chunk.to_vec(),
1562                    ..filter.clone()
1563                };
1564                let (where_sql, params) = build_entity_where(&namespace, &chunk_filter);
1565                let sql = entity_list_query(
1566                    &chunk_filter,
1567                    build_entity_count_query(&chunk_filter, &where_sql),
1568                    force_index && index.is_some(),
1569                );
1570                let mut stmt = conn.prepare(&sql)?;
1571                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1572                    params.iter().map(|p| p.as_ref()).collect();
1573                let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1574                total += count as u64;
1575            }
1576            Ok(total)
1577        })
1578        .await
1579    }
1580}
1581
1582// =============================================================================
1583// DDL
1584// =============================================================================
1585
1586const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
1587
1588pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1589    conn.execute_batch(ENTITIES_DDL)
1590}
1591
1592#[cfg(test)]
1593#[path = "entity_tests.rs"]
1594mod tests;
1595
1596#[cfg(test)]
1597#[path = "entity_type_counts_tests.rs"]
1598mod entity_type_counts_tests;
1599
1600#[cfg(test)]
1601#[path = "entity_busy_tests.rs"]
1602mod direct_busy_tests;
1603
1604#[cfg(test)]
1605#[path = "entity_list_plan_tests.rs"]
1606mod entity_list_plan_tests;
1607
1608#[cfg(test)]
1609#[path = "entity_merge_tombstone_tests.rs"]
1610mod entity_merge_tombstone_tests;