Skip to main content

autumn_search/
postgres.rs

1//! The Postgres [`SearchBackend`] and [`DocumentSource`].
2//!
3//! Reuses the in-core FTS primitives from #842 — `to_tsvector` / `setweight` /
4//! `plainto_tsquery` / `ts_rank_cd` — and adds vectors, with `pgvector` when
5//! the extension is available.
6//!
7//! # Why a framework-owned index table
8//!
9//! Documents live in **one** table, `autumn_search_documents`, keyed
10//! `(index_name, record_id)`, rather than in each model's own `search_vector`
11//! column. That buys three things the per-table column cannot:
12//!
13//! - the index is **engine-agnostic** — swapping to Meilisearch changes the
14//!   backend, not every model's migration;
15//! - it is **observable and repairable** — you can count, diff, and purge the
16//!   index without touching the system of record;
17//! - a **backfill** and an incremental reindex write through the exact same
18//!   path, so bootstrapping and steady state cannot disagree.
19//!
20//! The model's own `#[searchable]` column (and `#[repository(searchable)]`'s
21//! `search()`) is untouched: this subsumes #842 as one backend, it does not
22//! replace it.
23//!
24//! # Vectors: `pgvector` when present, portable when not
25//!
26//! `ensure_index` probes for the `vector` extension. When it is installed
27//! **and** `search.embedding_dimensions` is configured, embeddings are written
28//! to a `vector(N)` column with an ivfflat index and k-NN uses the `<=>`
29//! cosine-distance operator. Otherwise they are written to a portable
30//! `double precision[]` column and ranked with an `autumn_search_cosine()` SQL
31//! function created by the same migration. Same API, same ordering, different
32//! speed — so the plugin is deployable on a managed Postgres without
33//! `pgvector`, and gets the fast path for free where it exists.
34
35use std::sync::atomic::{AtomicBool, Ordering};
36use std::sync::{OnceLock, RwLock};
37
38use autumn_web::pagination::Page;
39use autumn_web::search::{IndexDefinition, SearchDocument};
40use diesel::sql_types::{BigInt, Double, Nullable, Text};
41use diesel_async::RunQueryDsl;
42use diesel_async::pooled_connection::deadpool::Pool;
43
44use crate::backend::{
45    BackendCapabilities, BoxFuture, IndexedDocument, KeywordQuery, SearchBackend, SearchFilter,
46    SearchHit, VectorQuery, empty_page,
47};
48use crate::error::{SearchError, SearchResult};
49use crate::source::DocumentSource;
50use crate::text::query_tokens;
51
52type RuntimePool = Pool<autumn_web::RuntimeConnection>;
53
54/// A `sql_query` with its parameters still to be bound.
55type BoxedQuery<'a> = diesel::query_builder::BoxedSqlQuery<
56    'a,
57    autumn_web::RuntimeBackend,
58    diesel::query_builder::SqlQuery,
59>;
60
61/// Physical table every index's documents live in.
62pub const DOCUMENTS_TABLE: &str = "autumn_search_documents";
63
64/// Ledger of deleted records, consulted by a watermarked (backfill) insert.
65pub const DELETES_TABLE: &str = "autumn_search_deletes";
66
67/// How long a delete stays in [`DELETES_TABLE`].
68///
69/// It only has to outlive the longest backfill that could still be holding a
70/// stale batch. Seven days is far beyond that and keeps the table small on a
71/// delete-heavy index; pruning runs once per process, at `ensure_index`.
72const DELETE_RETENTION: &str = "7 days";
73
74/// `ts_rank_cd`'s `{D, C, B, A}` weight array, matching
75/// [`weight_factor`](autumn_web::search::weight_factor).
76///
77/// Postgres' default is `{0.1, 0.2, 0.4, 1.0}`, but the framework's
78/// cross-backend ranking contract is `D:1, C:2, B:5, A:10` — `0.5` for `B`,
79/// not `0.4`. Leaving it defaulted means a query whose `B` and `C` matches
80/// compete ranks differently here than in `MemorySearchBackend`, which the two
81/// suites assert is the *same* contract. Kept as a literal (not formatted at
82/// call time) so the statement text is stable for the prepared-statement
83/// cache; `ts_rank_weights_track_the_framework_factors` fails if
84/// `weight_factor` changes without this.
85const TS_RANK_WEIGHTS: &str = "'{0.1, 0.2, 0.5, 1.0}'::float4[]";
86
87/// How embeddings are physically stored.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89#[non_exhaustive]
90pub enum VectorMode {
91    /// `pgvector` is installed and a dimension is configured: a `vector(N)`
92    /// column plus an ivfflat index, queried with `<=>`.
93    PgVector {
94        /// Declared embedding width.
95        dimensions: usize,
96    },
97    /// Portable fallback: `double precision[]` ranked by an
98    /// `autumn_search_cosine()` SQL function.
99    Array,
100}
101
102impl VectorMode {
103    /// Whether this mode uses the `pgvector` extension.
104    #[must_use]
105    pub const fn is_pgvector(self) -> bool {
106        matches!(self, Self::PgVector { .. })
107    }
108}
109
110// ── Rows ────────────────────────────────────────────────────────────────────
111
112#[derive(diesel::QueryableByName)]
113struct HitRow {
114    #[diesel(sql_type = BigInt)]
115    record_id: i64,
116    #[diesel(sql_type = Double)]
117    score: f64,
118}
119
120#[derive(diesel::QueryableByName)]
121struct CountRow {
122    #[diesel(sql_type = BigInt)]
123    total: i64,
124}
125
126#[derive(diesel::QueryableByName)]
127struct EmbeddingRow {
128    #[diesel(sql_type = Nullable<Text>)]
129    embedding: Option<String>,
130}
131
132#[derive(diesel::QueryableByName)]
133struct SourceRow {
134    #[diesel(sql_type = BigInt)]
135    id: i64,
136    #[diesel(sql_type = Text)]
137    fields: String,
138    #[diesel(sql_type = Nullable<Text>)]
139    tenant_id: Option<String>,
140}
141
142#[derive(diesel::QueryableByName)]
143struct WatermarkRow {
144    #[diesel(sql_type = Text)]
145    watermark: String,
146}
147
148#[derive(diesel::QueryableByName)]
149struct WidthRow {
150    #[diesel(sql_type = BigInt)]
151    width: i64,
152}
153
154#[derive(diesel::QueryableByName)]
155struct ExistsRow {
156    #[diesel(sql_type = diesel::sql_types::Bool)]
157    present: bool,
158}
159
160/// Which optional framework columns a model's table carries.
161///
162/// `bool_or` over zero rows is NULL, so both fields are nullable — a table
163/// that does not exist yet reads as "neither column".
164#[derive(diesel::QueryableByName)]
165struct OptionalColumnsRow {
166    #[diesel(sql_type = Nullable<diesel::sql_types::Bool>)]
167    has_tenant: Option<bool>,
168    #[diesel(sql_type = Nullable<diesel::sql_types::Bool>)]
169    has_deleted_at: Option<bool>,
170}
171
172// ── The store ───────────────────────────────────────────────────────────────
173
174/// Postgres-backed search storage.
175///
176/// Implements **both** [`SearchBackend`] (the index) and [`DocumentSource`]
177/// (reading records back out of their own tables), because both need the same
178/// connection pool and the same identifier-safety rules. `SearchPlugin::postgres()`
179/// installs one instance as both.
180///
181/// The pool is installed at startup ([`PostgresSearchStore::install_pool`]),
182/// so the store can be constructed by the plugin builder before an `AppState`
183/// exists.
184pub struct PostgresSearchStore {
185    pool: OnceLock<RuntimePool>,
186    /// Configured embedding width; `None` disables the `pgvector` fast path.
187    dimensions: Option<usize>,
188    /// Resolved at `ensure_index` time, once per process.
189    vector_mode: RwLock<Option<VectorMode>>,
190    /// Whether the shared DDL has already run in this process. The statements
191    /// are identical for every index, so running them per index is N× the work
192    /// and N× the concurrent-boot race window.
193    schema_ready: AtomicBool,
194    /// Cache of the optional framework columns each source table carries, so a
195    /// backfill does not re-probe `pg_attribute` on every batch.
196    optional_columns: RwLock<std::collections::HashMap<&'static str, OptionalColumns>>,
197    /// Declared width of the PHYSICAL `embedding_vec` column, if it exists —
198    /// which is NOT the same question as "is this process in pgvector mode".
199    ///
200    /// An earlier pgvector-enabled deployment can leave the column behind while
201    /// this process falls back to `Array` (dimensions unset, or a width
202    /// mismatch). Writes must still keep that column in step, or stale vectors
203    /// survive every upsert and a pgvector-mode reader — another replica, or
204    /// this one after a config change — silently ranks on obsolete content.
205    vector_column_width: RwLock<Option<usize>>,
206}
207
208/// Which optional framework columns one source table carries.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210struct OptionalColumns {
211    tenant_id: bool,
212    deleted_at: bool,
213}
214
215impl std::fmt::Debug for PostgresSearchStore {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.debug_struct("PostgresSearchStore")
218            .field("pool_installed", &self.pool.get().is_some())
219            .field("dimensions", &self.dimensions)
220            .field("vector_mode", &self.vector_mode())
221            // UFCS: `RunQueryDsl::load` is in scope and shadows `AtomicBool::load`.
222            .field(
223                "schema_ready",
224                &AtomicBool::load(&self.schema_ready, Ordering::SeqCst),
225            )
226            .finish_non_exhaustive()
227    }
228}
229
230impl PostgresSearchStore {
231    /// Create a store whose pool is installed later.
232    #[must_use]
233    pub fn new(dimensions: Option<usize>) -> Self {
234        Self {
235            pool: OnceLock::new(),
236            dimensions,
237            vector_mode: RwLock::new(None),
238            schema_ready: AtomicBool::new(false),
239            optional_columns: RwLock::new(std::collections::HashMap::new()),
240            vector_column_width: RwLock::new(None),
241        }
242    }
243
244    /// Install the application's connection pool. Idempotent; the first call
245    /// wins.
246    pub fn install_pool(&self, pool: RuntimePool) {
247        let _ = self.pool.set(pool);
248    }
249
250    /// The resolved vector storage mode, once `ensure_index` has run.
251    #[must_use]
252    pub fn vector_mode(&self) -> Option<VectorMode> {
253        self.vector_mode.read().ok().and_then(|guard| *guard)
254    }
255
256    /// Declared width of the physical `embedding_vec` column, if it exists.
257    ///
258    /// Independent of [`Self::vector_mode`]: the column can outlive the mode
259    /// that created it.
260    #[must_use]
261    pub fn vector_column_width(&self) -> Option<usize> {
262        self.vector_column_width
263            .read()
264            .ok()
265            .and_then(|guard| *guard)
266    }
267
268    /// Upsert `documents`, optionally refusing to clobber rows written since
269    /// `watermark`. The single write path behind both
270    /// [`SearchBackend::index`] and [`SearchBackend::index_unless_newer`], so
271    /// the conditional and unconditional forms cannot drift apart.
272    ///
273    /// Issues ONE statement per CHUNK of the batch (almost always exactly
274    /// one statement total), not one per document.
275    /// [`SearchClient::backfill`](crate::SearchClient::backfill) drives this
276    /// with up to `DEFAULT_BACKFILL_BATCH` (500) documents at a time, so a
277    /// per-document round trip turned a full-table backfill into one DB
278    /// round trip per row — invisible in a buffer ranking (each statement is
279    /// cheap) but dominant in `pg_stat_statements.calls`. `index_name` and
280    /// `language` are the same for every document in one call, so they are
281    /// bound ONCE (`$1`, `$2`) per chunk and referenced by every row, which
282    /// is also what keeps a chunk's statement shape — and so its place in
283    /// diesel's prepared-statement cache — independent of chunk size.
284    ///
285    /// `documents` is a caller-supplied slice on a PUBLIC trait method, not
286    /// something this type controls the shape of — [`SearchDocument::fields`]
287    /// is itself a public, uncapped `Vec` a hand-built document can push
288    /// arbitrarily many (even repeated) entries onto — so five defenses
289    /// apply before any SQL is built:
290    ///
291    /// - **duplicate `record_id`s are deduplicated** — a single statement's
292    ///   `ON CONFLICT DO UPDATE` cannot affect the same conflict target
293    ///   twice (Postgres: "ON CONFLICT DO UPDATE command cannot affect row a
294    ///   second time"), so an un-deduplicated multi-row statement would turn
295    ///   a batch a hand-built caller (or a third-party `DocumentSource`) is
296    ///   entitled to send into a hard error. Which occurrence survives
297    ///   depends on `watermark`, because the OLD per-document loop's
298    ///   observable behavior differed between the two forms:
299    ///     - **unconditional** (`watermark` is `None`): N sequential
300    ///       UPSERTs with no guard, so the LAST occurrence's write is what a
301    ///       later occurrence would overwrite. Deduplicate keeping the LAST.
302    ///     - **watermark-guarded** (`index_unless_newer`): the FIRST
303    ///       occurrence's UPSERT sets `updated_at = NOW()`, which is now
304    ///       newer than `watermark` — so every LATER occurrence's own guard
305    ///       (`WHERE updated_at <= watermark`) fails against that
306    ///       just-written row and silently no-ops. Deduplicate keeping the
307    ///       FIRST, to match.
308    /// - **every document's embedding width is validated BEFORE
309    ///   deduplication** — a malformed duplicate that loses the
310    ///   keep-first/keep-last coin flip would otherwise be silently
311    ///   discarded, masking malformed embedder output the old per-document
312    ///   loop always rejected with `DimensionMismatch` (#2311). Only the
313    ///   pgvector-mode + physical-`embedding_vec`-column combination
314    ///   rejects; other modes keep the row loop's existing behavior
315    ///   (a stale column copy is repaired by `NULL`ing it, not by failing
316    ///   the batch).
317    /// - **the batch is split into chunks sized so no single statement can
318    ///   approach Postgres's 65,535 bind-parameter limit**, using each
319    ///   document's ACTUAL bind count (not `definition.fields.len()`, which
320    ///   undercounts a document built with repeated field entries) —
321    ///   however large `BackfillOptions::batch_size`, however many fields
322    ///   `definition` declares, or however a hand-built document's own
323    ///   `fields` was constructed. Each chunk is still its own atomic
324    ///   statement; a batch that needed more than one chunk (never true at
325    ///   the framework's own 500-document default with a realistic field
326    ///   count) is atomic per CHUNK, not across the whole call — a narrower
327    ///   guarantee than the single-chunk case, and disclosed as such rather
328    ///   than silently assumed.
329    /// - **a chunk also caps at `MAX_ROWS_PER_CHUNK` rows**, independent of
330    ///   the parameter budget — a document with few or no weighted fields
331    ///   consumes very few binds, so the param budget alone could still
332    ///   admit thousands of rows into one statement. The watermark-guarded
333    ///   shape joins rows with `UNION ALL`, and a deeply nested chain of
334    ///   those risks Postgres' `max_stack_depth` during parsing even while
335    ///   comfortably under the bind limit.
336    /// - **the deduplicated batch is sorted by `record_id`**, so every
337    ///   writer of overlapping ids locks conflict rows in the SAME order.
338    ///   The old per-document loop held at most one row lock at a time
339    ///   (autocommit per statement); a multi-row statement can hold several
340    ///   at once, so two batches indexing the same ids in different orders
341    ///   could otherwise lock-and-wait on each other in a cycle — a
342    ///   deadlock a per-document loop could never produce. A fixed order
343    ///   across every caller makes that cycle impossible, and changes
344    ///   nothing about the query's result, since each row's own
345    ///   `ON CONFLICT` target is independent of row order.
346    #[allow(clippy::too_many_lines)] // one linear per-row statement builder, clearest unsplit
347    async fn write_documents(
348        &self,
349        definition: &IndexDefinition,
350        documents: &[IndexedDocument],
351        watermark: Option<&str>,
352    ) -> SearchResult<()> {
353        use std::fmt::Write as _;
354
355        // Leaves comfortable headroom under Postgres' real 65,535
356        // bind-parameter cap for the few shared params (`$1`/`$2`[/`$3`]).
357        const PARAM_BUDGET: usize = 60_000;
358
359        // A separate cap from `PARAM_BUDGET`: a document with few or no weighted
360        // fields consumes very few binds, so the param budget alone could still
361        // admit thousands of rows into one statement. The watermarked shape joins
362        // rows with `UNION ALL`, and a deeply nested `SELECT ... UNION ALL ...`
363        // chain risks Postgres's `max_stack_depth` during parsing and planning — a
364        // failure mode neither the old per-document loop nor the unconditional
365        // `VALUES` form, a flat list rather than a nested tree, has. Comfortably
366        // above the framework's own 500-document default batch, so it never engages
367        // for realistic use.
368        const MAX_ROWS_PER_CHUNK: usize = 1_000;
369
370        checked(definition)?;
371        if documents.is_empty() {
372            return Ok(());
373        }
374        let mut conn = self.conn().await?;
375        // Keyed on the PHYSICAL column, not on this process's mode. The
376        // column can outlive the mode that created it (dimensions unset, a
377        // width mismatch, a mixed fleet), and skipping it then leaves a
378        // stale `embedding_vec` behind after every upsert — which a
379        // pgvector-mode reader silently ranks on. `None` width means the
380        // column is absent and must stay out of the column list entirely.
381        let vector_width = self.vector_column_width();
382        let (vec_column, vec_update) = if vector_width.is_some() {
383            (
384                ", embedding_vec",
385                ", embedding_vec = EXCLUDED.embedding_vec",
386            )
387        } else {
388            ("", "")
389        };
390
391        // Every supplied document is validated BEFORE `dedupe_by_id`
392        // selects the duplicate winner: a malformed duplicate that loses the
393        // keep-first/keep-last coin flip would otherwise be silently
394        // discarded, masking malformed embedder output that the old
395        // per-document loop always rejected (#2311). This runs over the full
396        // supplied slice, not the survivors.
397        validate_embedding_widths(documents, vector_width, self.vector_mode())?;
398
399        // See the doc comment above for why the direction depends on
400        // `watermark`.
401        let mut kept_documents = dedupe_by_id(documents, watermark.is_some());
402
403        // Sorted by `record_id` so every writer of overlapping ids — concurrent
404        // callers, or successive backfill batches — locks conflict rows in the same
405        // order. The old per-document loop held at most one row lock at a time,
406        // autocommitting per statement, so it could never deadlock on row-lock
407        // order; a multi-row statement can hold several at once, and two batches
408        // indexing the same ids in different orders, `[1, 2]` against `[2, 1]`,
409        // could lock and wait on each other in a cycle. A fixed ascending order
410        // across every caller makes that impossible. Sorting after dedup, not
411        // before, because a canonical order only has to hold across the surviving
412        // ids, not whichever occurrence of a duplicate lost — and it changes nothing
413        // about the query result, since each row's `ON CONFLICT` target is
414        // independent of row order.
415        kept_documents.sort_unstable_by_key(|document| document.id());
416
417        let mut cursor = 0usize;
418        while cursor < kept_documents.len() {
419            // $1 index_name, $2 language[, $3 watermark] — shared by every row
420            // in this chunk's statement.
421            let mut binds = vec![
422                Bound::Text(definition.name.to_owned()),
423                Bound::Text(definition.language.to_owned()),
424            ];
425            let watermark_param = watermark.map(|value| {
426                binds.push(Bound::Text(value.to_owned()));
427                3usize
428            });
429            let mut next_param: usize = if watermark_param.is_some() { 4 } else { 3 };
430
431            let mut rows = Vec::new();
432            let mut record_ids = Vec::new();
433
434            while let Some(&document) = kept_documents.get(cursor) {
435                // This document's EXACT bind count: 5 fixed params, +1 for
436                // the vector literal if the column exists, +1 per field
437                // VALUE the document actually carries that `definition`
438                // also declares a weight for — mirrors the loop below
439                // exactly (same predicate, `definition.weight_of`), so a
440                // document with repeated field entries is measured
441                // precisely rather than assumed to carry at most one value
442                // per declared field.
443                let doc_param_count = 5usize
444                    .saturating_add(usize::from(vector_width.is_some()))
445                    .saturating_add(
446                        document
447                            .document
448                            .fields
449                            .iter()
450                            .filter(|field| definition.weight_of(field.name).is_some())
451                            .count(),
452                    );
453                if !rows.is_empty()
454                    && (next_param.saturating_add(doc_param_count) > PARAM_BUDGET
455                        || rows.len() >= MAX_ROWS_PER_CHUNK)
456                {
457                    // Would overflow this chunk's param budget or row-count cap:
458                    // stop here without consuming `document`, leaving `cursor`
459                    // untouched, and let the outer `while` start a fresh chunk for
460                    // it. A chunk with zero rows so far always admits at least one
461                    // document whatever its size, so a single document pathological
462                    // enough to exceed the budget alone gets exactly the old
463                    // per-document loop's behaviour: its own statement, which
464                    // Postgres would equally have rejected before this change.
465                    break;
466                }
467                cursor = cursor.saturating_add(1);
468
469                let record_id = document.id();
470                record_ids.push(record_id);
471                let record_id_param = format!("${next_param}");
472                binds.push(Bound::BigInt(record_id));
473                next_param = next_param.saturating_add(1);
474
475                let tenant_param = format!("${next_param}");
476                binds.push(Bound::NullableText(document.document.tenant_id.clone()));
477                next_param = next_param.saturating_add(1);
478
479                let fields_param = format!("${next_param}");
480                binds.push(Bound::Text(fields_json(document)?));
481                next_param = next_param.saturating_add(1);
482
483                let content_param = format!("${next_param}");
484                binds.push(Bound::Text(document.document.text()));
485                next_param = next_param.saturating_add(1);
486
487                let embedding_param = format!("${next_param}");
488                binds.push(Bound::NullableText(
489                    document.embedding.as_deref().map(array_literal),
490                ));
491                next_param = next_param.saturating_add(1);
492
493                let vec_value = if let Some(width) = vector_width {
494                    let literal = match document.embedding.as_deref() {
495                        // A width the column cannot hold would fail the insert, and
496                        // leaving the old value in place is exactly the staleness
497                        // this avoids, so it never reaches SQL.
498                        //
499                        // Whether that is an error depends on which column this
500                        // process actually reads. In pgvector mode k-NN runs off
501                        // `embedding_vec`, so writing NULL would leave the row
502                        // permanently invisible to semantic search while every write
503                        // reported success — a silent hole, and the worst outcome
504                        // available. Say so instead.
505                        //
506                        // Returning here leaves this chunk's statement unexecuted, so
507                        // a document earlier in the same chunk never reaches SQL
508                        // either, unlike the old per-document loop where earlier rows
509                        // would already be committed. A prior chunk, if any, has
510                        // already committed: see the doc comment above — atomic per
511                        // chunk, not across the whole call.
512                        Some(embedding) if embedding.len() != width => {
513                            if self.vector_mode().is_some_and(VectorMode::is_pgvector) {
514                                return Err(SearchError::DimensionMismatch {
515                                    expected: width,
516                                    actual: embedding.len(),
517                                });
518                            }
519                            // Not in pgvector mode: `embedding_vec` is a
520                            // leftover column from a previous width, k-NN runs
521                            // off the portable `embedding` array (bound above,
522                            // at full width), and NULLing the stale copy is the
523                            // correct repair rather than a loss.
524                            None
525                        }
526                        other => other.map(vector_literal),
527                    };
528                    let placeholder = format!(", ${next_param}::vector");
529                    binds.push(Bound::NullableText(literal));
530                    next_param = next_param.saturating_add(1);
531                    placeholder
532                } else {
533                    String::new()
534                };
535
536                // The weighted tsvector, built exactly as #842 does:
537                // `setweight(to_tsvector(<lang>, <value>), <weight>)` per field,
538                // concatenated. The weight letter is interpolated, so it comes from
539                // the validated definition rather than from the document: a
540                // hand-built document, or a third-party `DocumentSource`, can carry
541                // any `char`. A field the index does not declare is skipped.
542                let mut vector_sql = String::new();
543                for field in &document.document.fields {
544                    let Some(weight) = definition.weight_of(field.name) else {
545                        continue;
546                    };
547                    if !vector_sql.is_empty() {
548                        vector_sql.push_str(" || ");
549                    }
550                    // `$2::text::regconfig`, not `$2::regconfig`: the same
551                    // parameter is also the `language` column value, and the
552                    // untyped form is "inconsistent types deduced for
553                    // parameter" under any client that does not declare bind
554                    // types.
555                    let field_value_param = format!("${next_param}");
556                    let _ = write!(
557                        vector_sql,
558                        "setweight(to_tsvector($2::text::regconfig, {field_value_param}), '{weight}')"
559                    );
560                    binds.push(Bound::Text(field.value.clone()));
561                    next_param = next_param.saturating_add(1);
562                }
563                if vector_sql.is_empty() {
564                    "to_tsvector($2::text::regconfig, '')".clone_into(&mut vector_sql);
565                }
566
567                let columns = format!(
568                    "$1, {record_id_param}, {tenant_param}, $2, {fields_param}::jsonb, \
569                 {content_param}, {vector_sql}, {embedding_param}::double precision[]{vec_value}"
570                );
571                rows.push(UpsertRow {
572                    columns,
573                    record_id_param,
574                });
575            }
576
577            // Only consulted by `upsert_sql` on the unconditional path (the
578            // ledger clear), but the slot number and the bind must agree either
579            // way, so both are decided here, once, right after the loop that
580            // fixed `next_param`.
581            let ids_param = next_param;
582            if watermark_param.is_none() {
583                binds.push(Bound::Ids(record_ids));
584            }
585
586            let sql = upsert_sql(&rows, vec_column, vec_update, ids_param, watermark_param);
587
588            bind_all(
589                diesel::sql_query(sql).into_boxed::<autumn_web::RuntimeBackend>(),
590                binds,
591            )
592            .execute(&mut conn)
593            .await
594            .map_err(SearchError::backend)?;
595        }
596
597        Ok(())
598    }
599
600    fn pool(&self) -> SearchResult<&RuntimePool> {
601        require_postgres_runtime()?;
602        self.pool.get().ok_or_else(|| {
603            SearchError::Backend(
604                "the search store has no database pool; is `database.primary_url` configured?"
605                    .to_owned(),
606            )
607        })
608    }
609
610    async fn conn(
611        &self,
612    ) -> SearchResult<
613        diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
614    > {
615        self.pool()?.get().await.map_err(SearchError::backend)
616    }
617}
618
619/// Narrow a SQL `double precision` score to the `f32` a [`SearchHit`] carries.
620///
621/// Relevance scores are ranking-only and comparable within a single result
622/// set, so `f32` precision is ample; the saturating cast keeps an out-of-range
623/// value finite rather than turning it into an infinity that would poison a
624/// sort.
625#[allow(
626    clippy::cast_possible_truncation,
627    reason = "scores are ranking-only; f32 precision is deliberate"
628)]
629const fn narrow_score(score: f64) -> f32 {
630    if score.is_finite() { score as f32 } else { 0.0 }
631}
632
633/// Validate a definition and return it, so no caller can interpolate an
634/// unvalidated identifier into SQL.
635fn checked(definition: &IndexDefinition) -> SearchResult<()> {
636    definition
637        .validate()
638        .map_err(|e| SearchError::InvalidIndex(e.to_string()))
639}
640
641/// Render an embedding as a Postgres `double precision[]` literal.
642///
643/// Values are `f32`, so every element is a finite decimal produced by Rust's
644/// own float formatting — there is no path for caller text to reach this
645/// string. Non-finite values are clamped to `0`, which keeps a malformed
646/// embedding from producing invalid SQL.
647fn array_literal(vector: &[f32]) -> String {
648    let mut out = String::with_capacity(vector.len().saturating_mul(8).saturating_add(2));
649    out.push('{');
650    for (index, value) in vector.iter().enumerate() {
651        if index > 0 {
652            out.push(',');
653        }
654        let value = if value.is_finite() { *value } else { 0.0 };
655        out.push_str(&value.to_string());
656    }
657    out.push('}');
658    out
659}
660
661/// Render an embedding as a `pgvector` literal (`[1,2,3]`).
662fn vector_literal(vector: &[f32]) -> String {
663    let mut out = String::with_capacity(vector.len().saturating_mul(8).saturating_add(2));
664    out.push('[');
665    for (index, value) in vector.iter().enumerate() {
666        if index > 0 {
667            out.push(',');
668        }
669        let value = if value.is_finite() { *value } else { 0.0 };
670        out.push_str(&value.to_string());
671    }
672    out.push(']');
673    out
674}
675
676/// Parse a `double precision[]` / `vector` text representation back to `f32`s.
677fn parse_vector(raw: &str) -> Vec<f32> {
678    raw.trim_matches(['{', '}', '[', ']'])
679        .split(',')
680        .filter(|part| !part.trim().is_empty())
681        .filter_map(|part| part.trim().parse::<f32>().ok())
682        .collect()
683}
684
685/// SQL fragment restricting a query to `filter`, plus the parameters it needs.
686///
687/// Only **literal, developer-controlled** values are interpolated: record ids
688/// (`i64`, formatted by Rust) and field names taken from `definition`, which
689/// `IndexDefinition::validate` has already checked are bare identifiers. Every
690/// caller-supplied *value* is bound, never interpolated.
691///
692/// An `equals` key that `definition` does not declare renders as `AND FALSE`
693/// rather than reaching SQL. A key is an identifier position, so an unchecked
694/// one would let request text close the quote and `OR` away every restriction
695/// that preceded it — a full widening past the visibility filter, not merely
696/// an injection. Failing closed also matches [`SearchFilter::permits`], which
697/// returns `false` for a field the document does not carry.
698fn filter_sql(
699    definition: &IndexDefinition,
700    filter: &SearchFilter,
701    next_param: &mut usize,
702    binds: &mut Vec<Bound>,
703) -> String {
704    use std::fmt::Write as _;
705
706    let mut sql = String::new();
707
708    if let Some(tenant) = &filter.tenant_id {
709        let _ = write!(sql, " AND tenant_id = ${next_param}");
710        binds.push(Bound::Text(tenant.clone()));
711        *next_param = next_param.saturating_add(1);
712    }
713    if let Some(allowed) = &filter.allowed_ids {
714        if allowed.is_empty() {
715            // Unreachable in practice (callers short-circuit on
716            // `matches_nothing`), but keep the SQL itself fail-closed.
717            sql.push_str(" AND FALSE");
718        } else {
719            // `= ANY($n)` with a bound array, not an interpolated `IN (…)`
720            // list: the literal form would mint a distinct prepared statement
721            // for every distinct id set.
722            let _ = write!(sql, " AND record_id = ANY(${next_param})");
723            binds.push(Bound::Ids(allowed.clone()));
724            *next_param = next_param.saturating_add(1);
725        }
726    }
727    if !filter.excluded_ids.is_empty() {
728        let _ = write!(sql, " AND NOT (record_id = ANY(${next_param}))");
729        binds.push(Bound::Ids(filter.excluded_ids.clone()));
730        *next_param = next_param.saturating_add(1);
731    }
732    for (field, value) in &filter.equals {
733        if field == crate::backend::TENANT_FILTER_KEY {
734            let _ = write!(sql, " AND tenant_id = ${next_param}");
735        } else if definition.has_field(field) {
736            // Allowlisted against the definition, whose field names
737            // `IndexDefinition::validate` has already checked are bare
738            // identifiers; the *value* is bound.
739            let _ = write!(sql, " AND fields ->> '{field}' = ${next_param}");
740        } else {
741            // Not a declared field: match nothing, bind nothing, and do NOT
742            // advance the parameter counter.
743            sql.push_str(" AND FALSE");
744            continue;
745        }
746        binds.push(Bound::Text(value.clone()));
747        *next_param = next_param.saturating_add(1);
748    }
749    sql
750}
751
752/// The ` AND <score> >= $n` fragment for a minimum-score bound, advancing
753/// `next_param` when one is emitted.
754///
755/// The threshold is **bound, never interpolated**. `min_score` can come
756/// straight off a request, and diesel's prepared-statement cache is keyed on
757/// SQL text and never evicts — so a formatted value mints a permanent
758/// statement per distinct threshold, and a caller sweeping values grows the
759/// cache without bound until the server dies. That is the same failure the
760/// LIMIT/OFFSET binds exist to prevent, and the same one that made
761/// interpolated ids an OOM on every backfill.
762///
763/// Emitted only when a bound was asked for, because it repeats the
764/// (non-trivial) score expression. A non-finite bound is dropped rather than
765/// bound: "no threshold" is the safe reading of "not a number", and `>= NaN`
766/// would silently match nothing.
767fn score_threshold_sql(score_expr: &str, min_score: Option<f32>, next_param: &mut usize) -> String {
768    if !min_score.is_some_and(f32::is_finite) {
769        return String::new();
770    }
771    let sql = format!(" AND {score_expr} >= ${next_param}::double precision");
772    *next_param = next_param.saturating_add(1);
773    sql
774}
775
776/// The pgvector `ORDER BY`, which decides whether the ivfflat index may serve
777/// the query — and for a **filtered** query it must not.
778///
779/// ivfflat picks its candidate lists by distance BEFORE the `WHERE` clause
780/// runs, so a selective tenant or visibility predicate can leave the probed
781/// lists holding few or none of the rows the caller is allowed to see. The
782/// query then returns short, or empty, while qualifying neighbours sit in
783/// unprobed lists. That is a wrong answer to an authorization-scoped query
784/// rather than an approximate one, and it would diverge from the array
785/// backend, which the two suites assert implements the same contract.
786///
787/// Ordering by the derived similarity is opaque to the planner, so it forces
788/// an exact scan: slower, and right. An unfiltered query keeps the
789/// index-friendly `<=>` form, which is where the fast path actually pays.
790fn pgvector_order_by(filtered: bool) -> String {
791    if filtered {
792        "ORDER BY (1 - (embedding_vec <=> $2::vector)) DESC, record_id ASC".to_owned()
793    } else {
794        "ORDER BY (embedding_vec <=> $2::vector) ASC, record_id ASC".to_owned()
795    }
796}
797
798/// The pgvector similarity scan's `WHERE` predicate on the embedding column.
799///
800/// Beyond the NULL check, it excludes zero-norm stored vectors: pgvector's
801/// `<=>` returns NaN for them, and NaN sorts FIRST under `DESC`, so a
802/// filtered query's NaN rows would consume `LIMIT` slots ahead of valid
803/// neighbours — the Rust-side `is_finite` filter runs after `LIMIT` and
804/// cannot give those slots back (#2313). NaN is not equal to itself, so a
805/// self-equality test on the distance drops exactly the NaN rows. (The
806/// portable `autumn_search_cosine` yields 0 for zero-norm vectors rather
807/// than NaN, so the array predicate needs no such guard.)
808fn pgvector_embedding_predicate() -> String {
809    "embedding_vec IS NOT NULL AND (embedding_vec <=> $2::vector) = (embedding_vec <=> $2::vector)"
810        .to_owned()
811}
812
813/// References into `documents` keeping only ONE occurrence of each
814/// `record_id` — the FIRST if `keep_first`, else the LAST — in ascending
815/// original-index order.
816///
817/// `documents` is a caller-supplied slice on a PUBLIC trait method
818/// (`SearchBackend::index` / `index_unless_newer`), so nothing guarantees
819/// unique ids — a hand-built batch, or a third-party `DocumentSource`, is
820/// entitled to send the same record twice. A single multi-row statement
821/// cannot tolerate that the way the old per-document loop did (N sequential
822/// UPSERTs), because Postgres rejects `ON CONFLICT DO UPDATE` acting on the
823/// same conflict target twice within one statement — see
824/// [`PostgresSearchStore::write_documents`]'s doc comment for why the
825/// surviving occurrence has to differ between the watermarked and
826/// unconditional forms to match the old loop's observable result.
827fn dedupe_by_id(documents: &[IndexedDocument], keep_first: bool) -> Vec<&IndexedDocument> {
828    let mut index_for_id: std::collections::HashMap<i64, usize> =
829        std::collections::HashMap::with_capacity(documents.len());
830    for (index, document) in documents.iter().enumerate() {
831        if keep_first {
832            index_for_id.entry(document.id()).or_insert(index);
833        } else {
834            index_for_id.insert(document.id(), index);
835        }
836    }
837    let mut kept_indices: Vec<usize> = index_for_id.into_values().collect();
838    kept_indices.sort_unstable();
839    kept_indices
840        .into_iter()
841        .filter_map(|index| documents.get(index))
842        .collect()
843}
844
845/// Reject a batch carrying a wrong-width embedding BEFORE [`dedupe_by_id`]
846/// selects the duplicate winner.
847///
848/// The old per-document loop validated every document as it wrote it, so a
849/// malformed embedder output always surfaced as
850/// [`SearchError::DimensionMismatch`]. Deduplication runs first now, and a
851/// malformed duplicate that loses the keep-first/keep-last coin flip is
852/// silently discarded — an unconditional batch `[bad-width id=1, valid id=1]`
853/// succeeds, and a watermark-guarded batch `[valid id=1, bad-width id=1]`
854/// succeeds too. Malformed output from the app's embedder is then masked
855/// rather than reported (#2311).
856///
857/// This mirrors the per-document check the row loop in `write_documents`
858/// still applies: only the pgvector-mode + physical-`embedding_vec`-column
859/// combination rejects a width mismatch. Every other combination keeps the
860/// loop's existing behavior — notably the portable/`Array` mode, where a
861/// stale `embedding_vec` copy left by a previous width is repaired by `NULL`ing
862/// it rather than failing the batch.
863fn validate_embedding_widths(
864    documents: &[IndexedDocument],
865    vector_width: Option<usize>,
866    vector_mode: Option<VectorMode>,
867) -> SearchResult<()> {
868    let Some(width) = vector_width else {
869        // No physical column: nothing to validate against.
870        return Ok(());
871    };
872    if !vector_mode.is_some_and(VectorMode::is_pgvector) {
873        // Only pgvector-mode writers run k-NN off `embedding_vec`; other
874        // modes write the portable `embedding` array at full width and let
875        // the row loop repair a stale column copy by NULLing it.
876        return Ok(());
877    }
878    for document in documents {
879        if let Some(embedding) = document.embedding.as_deref()
880            && embedding.len() != width
881        {
882            return Err(SearchError::DimensionMismatch {
883                expected: width,
884                actual: embedding.len(),
885            });
886        }
887    }
888    Ok(())
889}
890
891/// The `record_id` order `delete()` binds for its ledger `INSERT ...
892/// unnest($2)`.
893///
894/// The insert acquires its tombstone rows in array order, so the array must
895/// be ascending to match the unconditional batch's `ORDER BY record_id FOR
896/// UPDATE` tombstone clear — opposite orders on the two paths deadlock over
897/// the same tombstones (#2310). Kept as a pure helper so the ordering
898/// contract is unit-testable without a database.
899fn ascending_ids(ids: &[i64]) -> Vec<i64> {
900    let mut sorted = ids.to_vec();
901    sorted.sort_unstable();
902    sorted
903}
904
905/// One row's already-rendered column-value expression list, in the exact
906/// order `upsert_sql`'s INSERT column list declares (`index_name,
907/// record_id, tenant_id, language, fields, content, search_vector,
908/// embedding[, embedding_vec]`) — built by [`PostgresSearchStore::write_documents`],
909/// one per document in the batch.
910///
911/// `record_id_param` is carried alongside `columns` rather than parsed back
912/// out of it, because the watermark guard (below) needs this row's OWN
913/// `record_id` placeholder to restrict the tombstone check to this record,
914/// and every row's placeholder number differs.
915struct UpsertRow {
916    columns: String,
917    record_id_param: String,
918}
919
920/// The document upsert for a whole batch of rows in ONE statement, optionally
921/// guarded by a watermark in parameter slot `watermark_param`. `ids_param`
922/// names the slot bound to the array of every row's `record_id`, consulted
923/// only on the unconditional (ledger-clearing) path below.
924///
925/// One statement rather than one per document: `write_documents` used to
926/// issue this per document, which turned a 500-document backfill batch into
927/// 500 round trips — invisible in a buffer-cost ranking (each statement is
928/// cheap) but dominant in `pg_stat_statements.calls`. Batching costs nothing
929/// in complexity here because every row's SQL is still built exactly as
930/// before (same per-field `setweight`/`to_tsvector` expression, same casts);
931/// this function only decides how the per-row fragments are joined into one
932/// statement.
933///
934/// The guard goes on **both** arms, and the two failures are different:
935///
936/// - the `DO UPDATE` arm covers a record a concurrent reindex REWROTE — a
937///   stale backfill batch would otherwise put the old text back;
938/// - the insert arm covers one it DELETED. With the row gone there is no
939///   conflict, so an unguarded insert re-creates the record and resurrects
940///   something a user deleted. `autumn_search_deletes` exists precisely
941///   because, once the document is gone, it is the only evidence the insert
942///   path has that anything happened.
943///
944/// `INSERT ... SELECT ... UNION ALL SELECT ...` rather than a multi-row
945/// `VALUES` when guarding, because `VALUES` admits no predicate and the
946/// delete guard would have nowhere to attach. `ON CONFLICT` applies to the
947/// unioned `SELECT` form just the same, and each arm can carry its own
948/// `record_id` while all still sharing parameter slot `watermark_param` for
949/// the watermark itself — bound once, not once per row.
950fn upsert_sql(
951    rows: &[UpsertRow],
952    vec_column: &str,
953    vec_update: &str,
954    ids_param: usize,
955    watermark_param: Option<usize>,
956) -> String {
957    let (guard, values_source) = watermark_param.map_or_else(
958        || {
959            (
960                String::new(),
961                format!(
962                    "VALUES {}",
963                    rows.iter()
964                        .map(|row| format!("({})", row.columns))
965                        .collect::<Vec<_>>()
966                        .join(", ")
967                ),
968            )
969        },
970        |slot| {
971            (
972                format!(" WHERE {DOCUMENTS_TABLE}.updated_at <= ${slot}::timestamptz"),
973                rows.iter()
974                    .map(|row| {
975                        format!(
976                            "SELECT {} \
977                             WHERE NOT EXISTS (SELECT 1 FROM {DELETES_TABLE} d \
978                               WHERE d.index_name = $1 AND d.record_id = {} \
979                                 AND d.deleted_at > ${slot}::timestamptz)",
980                            row.columns, row.record_id_param
981                        )
982                    })
983                    .collect::<Vec<_>>()
984                    .join(" UNION ALL "),
985            )
986        },
987    );
988    let upsert = format!(
989        "INSERT INTO {DOCUMENTS_TABLE} \
990           (index_name, record_id, tenant_id, language, fields, content, \
991            search_vector, embedding{vec_column}) \
992         {values_source} \
993         ON CONFLICT (index_name, record_id) DO UPDATE SET \
994           tenant_id = EXCLUDED.tenant_id, \
995           language = EXCLUDED.language, \
996           fields = EXCLUDED.fields, \
997           content = EXCLUDED.content, \
998           search_vector = EXCLUDED.search_vector, \
999           embedding = EXCLUDED.embedding{vec_update}, \
1000           updated_at = NOW(){guard}"
1001    );
1002
1003    if watermark_param.is_some() {
1004        // A watermarked (backfill) write leaves the ledger alone: its batch is
1005        // older than any tombstone by construction, so it has nothing to say
1006        // about whether the delete still stands.
1007        return upsert;
1008    }
1009
1010    // An unconditional write is the record coming back — a reindex read the row and found
1011    // it present, which supersedes any earlier delete — so the tombstone is cleared, or a
1012    // later backfill would skip a record that exists.
1013    //
1014    // In the same statement, for the reason the round before learned the hard way. As two
1015    // statements a delete can interleave: the upsert commits, the delete commits (document
1016    // removed, tombstone written), and then the trailing clear erases that newer tombstone
1017    // while the document stays absent, leaving a backfill able to see neither and resurrect
1018    // the record. One statement means the two possible serializations are "upsert+clear,
1019    // then delete" (document absent, tombstone present) and "delete, then upsert+clear"
1020    // (document present, no tombstone). Both are consistent; the inconsistent interleaving
1021    // cannot happen.
1022    //
1023    // `cleared` locks the ledger rows in ascending `record_id` order — via `ORDER BY`
1024    // plus `FOR UPDATE` in a `SELECT`, the only thing that actually controls Postgres's
1025    // row-lock acquisition order — matching `delete()`'s `INSERT ... unnest($2)` order.
1026    // A bare `DELETE ... WHERE record_id = ANY($n)` locks whatever rows its scan visits,
1027    // independent of the array's order, so racing an oppositely-ordered `delete()` over
1028    // the same tombstones could deadlock: each statement holds one ledger row and waits
1029    // for the other (#2310).
1030    format!(
1031        "WITH upserted AS ( \
1032           {upsert} \
1033         ), \
1034         cleared AS ( \
1035           SELECT record_id FROM {DELETES_TABLE} \
1036           WHERE index_name = $1 AND record_id = ANY(${ids_param}) \
1037           ORDER BY record_id \
1038           FOR UPDATE \
1039         ) \
1040         DELETE FROM {DELETES_TABLE} \
1041         WHERE index_name = $1 AND record_id IN (SELECT record_id FROM cleared)"
1042    )
1043}
1044
1045/// The bound value for [`score_threshold_sql`], if it emitted a fragment.
1046///
1047/// Kept next to it so the two cannot disagree about whether a parameter was
1048/// consumed — a mismatch would shift every later parameter by one.
1049fn score_threshold_bind(min_score: Option<f32>) -> Option<Bound> {
1050    min_score
1051        .filter(|min| min.is_finite())
1052        .map(|min| Bound::Double(f64::from(min)))
1053}
1054
1055/// One bound parameter.
1056///
1057/// Exists so a query can be assembled as `(sql, Vec<Bound>)` and bound in one
1058/// place, rather than every call site tracking two parallel lists of differing
1059/// SQL types.
1060#[derive(Debug, Clone, PartialEq)]
1061enum Bound {
1062    Text(String),
1063    NullableText(Option<String>),
1064    BigInt(i64),
1065    Double(f64),
1066    Ids(Vec<i64>),
1067}
1068
1069/// Bind `binds`, in order, onto a boxed query.
1070fn bind_all(mut query: BoxedQuery<'_>, binds: impl IntoIterator<Item = Bound>) -> BoxedQuery<'_> {
1071    for bound in binds {
1072        query = match bound {
1073            Bound::Text(value) => query.bind::<Text, _>(value),
1074            Bound::NullableText(value) => query.bind::<Nullable<Text>, _>(value),
1075            Bound::BigInt(value) => query.bind::<BigInt, _>(value),
1076            Bound::Double(value) => query.bind::<Double, _>(value),
1077            Bound::Ids(values) => bind_ids(query, values),
1078        };
1079    }
1080    query
1081}
1082
1083/// Refuse every use of this store on a `SQLite` build of autumn-web.
1084///
1085/// The store sends Postgres SQL. An app can build it and install a `SQLite`
1086/// pool without the plugin, so every trait method checks first, before any
1087/// early return, and the pool accessor checks too (#2539 §5).
1088#[allow(
1089    clippy::unnecessary_wraps,
1090    clippy::missing_const_for_fn,
1091    reason = "the SQLite arm of `backend_select!` returns an error"
1092)]
1093fn require_postgres_runtime() -> SearchResult<()> {
1094    autumn_web::backend_select! {
1095        pg => { Ok(()) },
1096        sqlite => {
1097            Err(SearchError::Backend(
1098                "PostgresSearchStore needs the Postgres backend, but this build of autumn-web \
1099                 uses SQLite (`--features sqlite`)"
1100                    .to_owned(),
1101            ))
1102        },
1103    }
1104}
1105
1106/// Bind an id list as a Postgres `BIGINT[]`.
1107///
1108/// `SQLite` has no array type. The store refuses every query on a `SQLite`
1109/// build ([`require_postgres_runtime`]), so that arm does not run. It binds
1110/// the ids as JSON text to keep the bind count correct and the crate free of
1111/// panics.
1112fn bind_ids(query: BoxedQuery<'_>, values: Vec<i64>) -> BoxedQuery<'_> {
1113    autumn_web::backend_select! {
1114        pg => { query.bind::<diesel::sql_types::Array<BigInt>, _>(values) },
1115        sqlite => { query.bind::<Text, _>(serde_json::Value::from(values).to_string()) },
1116    }
1117}
1118
1119impl SearchBackend for PostgresSearchStore {
1120    fn name(&self) -> &'static str {
1121        "postgres"
1122    }
1123
1124    fn capabilities(&self) -> BackendCapabilities {
1125        BackendCapabilities::default()
1126            .with_vector(true)
1127            .with_weighted_fields(true)
1128            .with_embedding_readback(true)
1129            .with_conditional_index(true)
1130    }
1131
1132    fn write_watermark(&self) -> BoxFuture<'_, SearchResult<Option<String>>> {
1133        Box::pin(async move {
1134            require_postgres_runtime()?;
1135            // The DATABASE's clock, not the process's. `updated_at` is set by
1136            // `NOW()` server-side, so comparing it against an app-side
1137            // timestamp would be at the mercy of clock skew between however
1138            // many app instances and the database — and the whole point of the
1139            // watermark is deciding which of two writes is newer.
1140            let mut conn = self.conn().await?;
1141            let row = diesel::sql_query("SELECT NOW()::text AS watermark")
1142                .get_result::<WatermarkRow>(&mut conn)
1143                .await
1144                .map_err(SearchError::backend)?;
1145            Ok(Some(row.watermark))
1146        })
1147    }
1148
1149    fn index_unless_newer<'a>(
1150        &'a self,
1151        definition: &'a IndexDefinition,
1152        documents: &'a [IndexedDocument],
1153        watermark: Option<&'a str>,
1154    ) -> BoxFuture<'a, SearchResult<()>> {
1155        Box::pin(async move {
1156            require_postgres_runtime()?;
1157            self.write_documents(definition, documents, watermark).await
1158        })
1159    }
1160
1161    fn ensure_index<'a>(
1162        &'a self,
1163        definition: &'a IndexDefinition,
1164    ) -> BoxFuture<'a, SearchResult<()>> {
1165        Box::pin(async move {
1166            require_postgres_runtime()?;
1167            checked(definition)?;
1168            let mut conn = self.conn().await?;
1169
1170            // The DDL is the same for every index, so run it once per process
1171            // rather than once per registered model.
1172            if !self.schema_ready.swap(true, Ordering::SeqCst)
1173                && let Err(error) = Self::apply_schema(&mut conn).await
1174            {
1175                // Let a later index retry the DDL rather than leaving the
1176                // process permanently believing the schema exists.
1177                self.schema_ready.store(false, Ordering::SeqCst);
1178                return Err(error);
1179            }
1180
1181            // Resolve the vector storage mode once per process. A managed
1182            // Postgres without `pgvector` must still work, so a failed
1183            // `CREATE EXTENSION` degrades rather than aborting boot.
1184            if self.vector_mode().is_none() {
1185                let mode = match self.dimensions {
1186                    // Threaded `&mut conn` rather than acquiring a second
1187                    // connection: holding two at boot deadlocks a pool of one.
1188                    Some(dimensions) if self.try_enable_pgvector(&mut conn, dimensions).await? => {
1189                        VectorMode::PgVector { dimensions }
1190                    }
1191                    _ => VectorMode::Array,
1192                };
1193                if let Ok(mut guard) = self.vector_mode.write() {
1194                    *guard = Some(mode);
1195                }
1196
1197                // Probe the PHYSICAL column regardless of the resolved mode: it
1198                // may have been created by an earlier deployment, and writes
1199                // have to keep it in step either way.
1200                let width = Self::vector_column_width_of(&mut conn).await?;
1201                if let Ok(mut guard) = self.vector_column_width.write() {
1202                    *guard = width;
1203                }
1204                tracing::info!(
1205                    ?mode,
1206                    vector_column_width = ?width,
1207                    "autumn-search postgres vector storage resolved"
1208                );
1209
1210                // Prune the delete ledger once per process. An entry only has
1211                // to outlive the longest backfill that could still hold a
1212                // stale batch; past that it is dead weight on a delete-heavy
1213                // index. Best-effort — a failure here must not abort boot.
1214                if let Err(error) = diesel::sql_query(format!(
1215                    "DELETE FROM {DELETES_TABLE} WHERE deleted_at < NOW() - INTERVAL '{DELETE_RETENTION}'"
1216                ))
1217                .execute(&mut conn)
1218                .await
1219                {
1220                    tracing::warn!(%error, "autumn-search could not prune the delete ledger");
1221                }
1222            }
1223            Ok(())
1224        })
1225    }
1226
1227    fn index<'a>(
1228        &'a self,
1229        definition: &'a IndexDefinition,
1230        documents: &'a [IndexedDocument],
1231    ) -> BoxFuture<'a, SearchResult<()>> {
1232        Box::pin(async move {
1233            require_postgres_runtime()?;
1234            self.write_documents(definition, documents, None).await
1235        })
1236    }
1237
1238    fn delete<'a>(
1239        &'a self,
1240        definition: &'a IndexDefinition,
1241        ids: &'a [i64],
1242    ) -> BoxFuture<'a, SearchResult<()>> {
1243        Box::pin(async move {
1244            require_postgres_runtime()?;
1245            checked(definition)?;
1246            // `IN ()` is a syntax error, so an empty slice must never reach SQL.
1247            if ids.is_empty() {
1248                return Ok(());
1249            }
1250            let mut conn = self.conn().await?;
1251            // One statement, via a data-modifying CTE.
1252            //
1253            // Removing the document and recording the delete are two halves of one
1254            // fact, and a concurrent backfill must never observe the gap between
1255            // them: after the DELETE commits but before the ledger row exists, a
1256            // watermarked insert sees no document to conflict with and no delete to
1257            // be blocked by, so it re-creates the record — and writing the tombstone
1258            // afterwards does not take it back out. Two autocommitted statements have
1259            // exactly that window.
1260            //
1261            // A CTE rather than an explicit transaction, because a single statement is
1262            // atomic under autocommit with nothing to roll back, and it keeps this off
1263            // diesel-async's transaction API. Postgres runs a data-modifying CTE to
1264            // completion whether or not the outer query reads it, so the DELETE needs
1265            // no RETURNING. `deleted_at` is refreshed on a replayed delete, so a retry
1266            // cannot age out earlier than the delete it repeats.
1267            //
1268            // `doomed` locks its rows in ascending `record_id` order — matching
1269            // `write_documents`' and `clear`'s order — via `ORDER BY` plus `FOR
1270            // UPDATE` in a `SELECT`, the only thing that actually controls Postgres's
1271            // row-lock acquisition order. Binding a pre-sorted array to `record_id =
1272            // ANY($2)` does not: Postgres may satisfy that predicate with a
1273            // sequential, bitmap, or index scan and locks rows in whatever order the
1274            // scan visits them, independent of the array's order, so a bare `DELETE
1275            // ... WHERE record_id = ANY($2)` could lock out of order and deadlock
1276            // against a concurrent `write_documents` batch.
1277            //
1278            // The ledger half needs the same treatment in reverse: the `INSERT ...
1279            // unnest($2)` below acquires its tombstone rows in array order, so the
1280            // array is pre-sorted ascending to match the unconditional batch's
1281            // `ORDER BY record_id FOR UPDATE` tombstone clear. Opposite orders on
1282            // the two paths deadlock over the same tombstones (#2310).
1283            let sorted_ids = ascending_ids(ids);
1284            bind_all(
1285                diesel::sql_query(format!(
1286                    "WITH doomed AS ( \
1287                       SELECT record_id FROM {DOCUMENTS_TABLE} \
1288                       WHERE index_name = $1 AND record_id = ANY($2) \
1289                       ORDER BY record_id \
1290                       FOR UPDATE \
1291                     ), \
1292                     removed AS ( \
1293                       DELETE FROM {DOCUMENTS_TABLE} \
1294                       WHERE index_name = $1 AND record_id IN (SELECT record_id FROM doomed) \
1295                     ) \
1296                     INSERT INTO {DELETES_TABLE} (index_name, record_id) \
1297                     SELECT $1, unnest($2::bigint[]) \
1298                     ON CONFLICT (index_name, record_id) DO UPDATE SET deleted_at = NOW()"
1299                ))
1300                .into_boxed::<autumn_web::RuntimeBackend>(),
1301                [
1302                    Bound::Text(definition.name.to_owned()),
1303                    Bound::Ids(sorted_ids),
1304                ],
1305            )
1306            .execute(&mut conn)
1307            .await
1308            .map_err(SearchError::backend)?;
1309            Ok(())
1310        })
1311    }
1312
1313    fn clear<'a>(&'a self, definition: &'a IndexDefinition) -> BoxFuture<'a, SearchResult<()>> {
1314        Box::pin(async move {
1315            require_postgres_runtime()?;
1316            checked(definition)?;
1317            let mut conn = self.conn().await?;
1318            // Documents and ledger in one statement, for the same reason `delete` uses
1319            // a CTE: a purge is a deliberate reset, and a concurrent write must not be
1320            // able to observe half of it. The ledger has to go too, or the rebuild that
1321            // follows would silently skip everything previously deleted.
1322            //
1323            // `doomed` locks its rows in ascending `record_id` order — `FOR UPDATE` on
1324            // a sorted `SELECT` acquires locks in the order rows are produced —
1325            // matching `write_documents`' and `delete`'s ascending order. A bare
1326            // `DELETE ... WHERE index_name = $1` would instead lock whatever rows its
1327            // scan visits, physical heap order for a sequential scan, which a
1328            // concurrent `write_documents` batch touching the same rows in ascending
1329            // order could deadlock against.
1330            diesel::sql_query(format!(
1331                "WITH doomed AS ( \
1332                   SELECT record_id FROM {DOCUMENTS_TABLE} \
1333                   WHERE index_name = $1 \
1334                   ORDER BY record_id \
1335                   FOR UPDATE \
1336                 ), \
1337                 removed AS ( \
1338                   DELETE FROM {DOCUMENTS_TABLE} \
1339                   WHERE index_name = $1 AND record_id IN (SELECT record_id FROM doomed) \
1340                 ) \
1341                 DELETE FROM {DELETES_TABLE} WHERE index_name = $1"
1342            ))
1343            .bind::<Text, _>(definition.name.to_owned())
1344            .execute(&mut conn)
1345            .await
1346            .map_err(SearchError::backend)?;
1347            Ok(())
1348        })
1349    }
1350
1351    fn keyword_search<'a>(
1352        &'a self,
1353        definition: &'a IndexDefinition,
1354        query: &'a KeywordQuery,
1355    ) -> BoxFuture<'a, SearchResult<Page<SearchHit>>> {
1356        Box::pin(async move {
1357            require_postgres_runtime()?;
1358            checked(definition)?;
1359            // Fail closed: a blank query and an impossible filter both return
1360            // an empty page having issued NO query — never a full scan.
1361            if query_tokens(&query.text).is_none() || query.filter.matches_nothing() {
1362                return Ok(empty_page(&query.page));
1363            }
1364
1365            let mut conn = self.conn().await?;
1366            // $1 index_name, $2 language, $3 query text.
1367            let mut param = 4_usize;
1368            let mut binds: Vec<Bound> = Vec::new();
1369            let predicate = filter_sql(definition, &query.filter, &mut param, &mut binds);
1370            let head = [
1371                Bound::Text(definition.name.to_owned()),
1372                Bound::Text(definition.language.to_owned()),
1373                Bound::Text(query.text.clone()),
1374            ];
1375
1376            // `plainto_tsquery` (not `websearch_to_tsquery`): the documented
1377            // cross-backend contract is "every token must match, operators are
1378            // not syntax". It is parameterized, so a hostile query string can
1379            // neither inject nor widen the result set.
1380            let where_clause = format!(
1381                "index_name = $1 \
1382                 AND search_vector @@ plainto_tsquery($2::text::regconfig, $3){predicate}"
1383            );
1384
1385            let total = bind_all(
1386                diesel::sql_query(format!(
1387                    "SELECT COUNT(*)::bigint AS total FROM {DOCUMENTS_TABLE} WHERE {where_clause}"
1388                ))
1389                .into_boxed::<autumn_web::RuntimeBackend>(),
1390                head.iter().cloned().chain(binds.iter().cloned()),
1391            )
1392            .get_result::<CountRow>(&mut conn)
1393            .await
1394            .map_err(SearchError::backend)?
1395            .total;
1396
1397            // LIMIT/OFFSET are BOUND: interpolating them would mint a distinct
1398            // prepared statement per page, which the unbounded statement cache
1399            // never evicts.
1400            let limit_param = param;
1401            let offset_param = param.saturating_add(1);
1402            let size = i64::from(query.page.size());
1403            let offset = i64::from(query.page.page().saturating_sub(1)).saturating_mul(size);
1404            let rows = bind_all(
1405                diesel::sql_query(format!(
1406                    "SELECT record_id, \
1407                            ts_rank_cd({TS_RANK_WEIGHTS}, search_vector, \
1408                                       plainto_tsquery($2::text::regconfig, $3))::double precision \
1409                              AS score \
1410                     FROM {DOCUMENTS_TABLE} WHERE {where_clause} \
1411                     ORDER BY score DESC, record_id ASC \
1412                     LIMIT ${limit_param} OFFSET ${offset_param}"
1413                ))
1414                .into_boxed::<autumn_web::RuntimeBackend>(),
1415                head.into_iter()
1416                    .chain(binds)
1417                    .chain([Bound::BigInt(size), Bound::BigInt(offset)]),
1418            )
1419            .load::<HitRow>(&mut conn)
1420            .await
1421            .map_err(SearchError::backend)?;
1422
1423            Ok(Page::new(
1424                rows.into_iter()
1425                    .map(|row| {
1426                        SearchHit::new(definition.name, row.record_id, narrow_score(row.score))
1427                    })
1428                    .collect(),
1429                total,
1430                &query.page,
1431            ))
1432        })
1433    }
1434
1435    fn vector_search<'a>(
1436        &'a self,
1437        definition: &'a IndexDefinition,
1438        query: &'a VectorQuery,
1439    ) -> BoxFuture<'a, SearchResult<Vec<SearchHit>>> {
1440        Box::pin(async move {
1441            require_postgres_runtime()?;
1442            checked(definition)?;
1443            if !definition.supports_vector_search() {
1444                return Err(SearchError::VectorUnsupported {
1445                    index: definition.name.to_owned(),
1446                });
1447            }
1448            if query.filter.matches_nothing() || query.limit == 0 || query.vector.is_empty() {
1449                return Ok(Vec::new());
1450            }
1451
1452            let mut conn = self.conn().await?;
1453            // $1 index_name, $2 the query vector (bound as text, cast in SQL).
1454            let mut param = 3_usize;
1455            let mut binds: Vec<Bound> = Vec::new();
1456            let predicate = filter_sql(definition, &query.filter, &mut param, &mut binds);
1457
1458            let pgvector = self.vector_mode().is_some_and(VectorMode::is_pgvector);
1459            // A filter here is an AUTHORIZATION boundary as often as not — a
1460            // tenant, a visibility allowlist, a `similar_to` self-exclusion.
1461            let filtered = query.filter != SearchFilter::default();
1462            // Both modes score cosine similarity, so the two orderings agree —
1463            // pgvector's `<=>` is cosine distance, hence `1 - d`. The ordering differs
1464            // deliberately: an ivfflat index can serve only `ORDER BY col <=> const
1465            // ASC`. Ordering by the derived `1 - d` expression is opaque to the planner
1466            // and forces an exact full scan, which would make the whole pgvector fast
1467            // path buy nothing. So pgvector mode orders by distance ascending and
1468            // converts to similarity only in the select list.
1469            let (query_vector, distance, score_expr, order_by, embedding_predicate) = if pgvector {
1470                (
1471                    vector_literal(&query.vector),
1472                    "(embedding_vec <=> $2::vector)".to_owned(),
1473                    "(1 - (embedding_vec <=> $2::vector))::double precision".to_owned(),
1474                    // The ordering decides whether the ivfflat index can serve
1475                    // this query, and for a filtered query it must not.
1476                    //
1477                    // ivfflat picks its candidate lists by distance before the
1478                    // `WHERE` clause runs, so a selective tenant or visibility
1479                    // predicate can leave the probed lists holding few or none of
1480                    // the rows the caller may see, returning short or empty while
1481                    // qualifying neighbours sit in unprobed lists. That is a wrong
1482                    // answer to an authorization-scoped query, not merely an
1483                    // approximate one, and it would differ from the array backend,
1484                    // which the two suites assert implements the same contract.
1485                    //
1486                    // Ordering by the derived similarity is opaque to the planner,
1487                    // so it forces an exact scan: slower, and right. An unfiltered
1488                    // query keeps the index-friendly form, where the fast path pays.
1489                    pgvector_order_by(filtered),
1490                    pgvector_embedding_predicate(),
1491                )
1492            } else {
1493                let expr = "autumn_search_cosine(embedding, $2::double precision[])".to_owned();
1494                (
1495                    array_literal(&query.vector),
1496                    String::new(),
1497                    expr.clone(),
1498                    format!("ORDER BY {expr} DESC, record_id ASC"),
1499                    // `unnest(a, b)` pads the shorter array with NULLs and silently
1500                    // mis-scores, so a width mismatch is excluded rather than ranked;
1501                    // pgvector's `<=>` errors instead, a documented divergence.
1502                    //
1503                    // The width is bound, not interpolated. It is the length of a
1504                    // caller-supplied vector, so formatting it mints a permanent
1505                    // prepared statement per distinct length in a cache that never
1506                    // evicts — the same unbounded growth the ids, limits, offsets, and
1507                    // score threshold are all bound to avoid. `array_length` returns
1508                    // `integer`, hence the cast.
1509                    {
1510                        let slot = param;
1511                        param = param.saturating_add(1);
1512                        binds.push(Bound::BigInt(
1513                            i64::try_from(query.vector.len()).unwrap_or(i64::MAX),
1514                        ));
1515                        format!(
1516                            "embedding IS NOT NULL AND array_length(embedding, 1) = ${slot}::integer"
1517                        )
1518                    },
1519                )
1520            };
1521            let _ = distance;
1522
1523            let threshold = score_threshold_sql(&score_expr, query.min_score, &mut param);
1524
1525            let limit_param = param;
1526            let limit = i64::try_from(query.limit).unwrap_or(i64::MAX);
1527            let rows = bind_all(
1528                diesel::sql_query(format!(
1529                    "SELECT record_id, {score_expr} AS score FROM {DOCUMENTS_TABLE} \
1530                     WHERE index_name = $1 AND {embedding_predicate}{predicate}{threshold} \
1531                     {order_by} LIMIT ${limit_param}"
1532                ))
1533                .into_boxed::<autumn_web::RuntimeBackend>(),
1534                [
1535                    Bound::Text(definition.name.to_owned()),
1536                    Bound::Text(query_vector),
1537                ]
1538                .into_iter()
1539                .chain(binds)
1540                // The threshold parameter, when one was emitted, sits between
1541                // the filter binds and the limit — matching the order `param`
1542                // handed the numbers out above.
1543                .chain(score_threshold_bind(query.min_score))
1544                .chain([Bound::BigInt(limit)]),
1545            )
1546            .load::<HitRow>(&mut conn)
1547            .await
1548            .map_err(SearchError::backend)?;
1549
1550            Ok(rows
1551                .into_iter()
1552                // A zero-norm vector makes pgvector's `<=>` return NaN, which
1553                // sorts FIRST under `DESC`. The `WHERE` predicate excludes
1554                // those rows before `LIMIT` (#2313); this filter stays as
1555                // defense-in-depth so a NaN score can never surface.
1556                .filter(|row| row.score.is_finite())
1557                .map(|row| SearchHit::new(definition.name, row.record_id, narrow_score(row.score)))
1558                .collect())
1559        })
1560    }
1561
1562    fn embedding<'a>(
1563        &'a self,
1564        definition: &'a IndexDefinition,
1565        id: i64,
1566        filter: &'a SearchFilter,
1567    ) -> BoxFuture<'a, SearchResult<Option<Vec<f32>>>> {
1568        Box::pin(async move {
1569            require_postgres_runtime()?;
1570            checked(definition)?;
1571            // A record the filter excludes reads back as absent — this is a
1572            // query like any other, and the seed id is caller-supplied.
1573            if filter.matches_nothing() {
1574                return Ok(None);
1575            }
1576            let mut conn = self.conn().await?;
1577            // $1 index_name, $2 record_id.
1578            let mut param = 3_usize;
1579            let mut binds: Vec<Bound> = Vec::new();
1580            let predicate = filter_sql(definition, filter, &mut param, &mut binds);
1581
1582            let rows = bind_all(
1583                diesel::sql_query(format!(
1584                    "SELECT embedding::text AS embedding FROM {DOCUMENTS_TABLE} \
1585                     WHERE index_name = $1 AND record_id = $2{predicate}"
1586                ))
1587                .into_boxed::<autumn_web::RuntimeBackend>(),
1588                [Bound::Text(definition.name.to_owned()), Bound::BigInt(id)]
1589                    .into_iter()
1590                    .chain(binds),
1591            )
1592            .load::<EmbeddingRow>(&mut conn)
1593            .await
1594            .map_err(SearchError::backend)?;
1595
1596            Ok(rows
1597                .into_iter()
1598                .next()
1599                .and_then(|row| row.embedding)
1600                .map(|raw| parse_vector(&raw)))
1601        })
1602    }
1603}
1604
1605impl PostgresSearchStore {
1606    /// Apply the shared DDL under an advisory lock.
1607    ///
1608    /// `CREATE TABLE/INDEX IF NOT EXISTS` and `CREATE OR REPLACE FUNCTION` are
1609    /// **not** race-free: concurrent boots hit `duplicate key value violates
1610    /// unique constraint "pg_type_typname_nsp_index"` and `tuple concurrently
1611    /// updated`. Without this, a rolling deploy would intermittently abort a
1612    /// replica's boot. A session-level advisory lock serializes them; it is
1613    /// released explicitly below and, if the process dies, when the connection
1614    /// closes.
1615    async fn apply_schema(
1616        conn: &mut diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
1617    ) -> SearchResult<()> {
1618        const LOCK: &str = "hashtext('autumn_search_schema')::bigint";
1619
1620        diesel::sql_query(format!("SELECT pg_advisory_lock({LOCK})"))
1621            .execute(&mut *conn)
1622            .await
1623            .map_err(SearchError::backend)?;
1624
1625        let mut outcome = Ok(());
1626        for statement in SCHEMA_STATEMENTS {
1627            if let Err(error) = diesel::sql_query(*statement).execute(&mut *conn).await {
1628                outcome = Err(SearchError::backend(error));
1629                break;
1630            }
1631        }
1632
1633        // Released on every path, including the error one. Each DDL statement
1634        // is its own transaction under autocommit, so a failure above does not
1635        // leave the session unable to run this.
1636        let _ = diesel::sql_query(format!("SELECT pg_advisory_unlock({LOCK})"))
1637            .execute(&mut *conn)
1638            .await;
1639
1640        outcome
1641    }
1642
1643    /// Declared width of the physical `embedding_vec` column, or `None` when
1644    /// the column does not exist.
1645    ///
1646    /// `atttypmod` carries a `vector`'s declared dimension count. Resolved via
1647    /// `to_regclass` so it follows the same `search_path` the queries do.
1648    async fn vector_column_width_of(
1649        conn: &mut diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
1650    ) -> SearchResult<Option<usize>> {
1651        let rows = diesel::sql_query(format!(
1652            "SELECT COALESCE(atttypmod, 0)::bigint AS width FROM pg_attribute \
1653             WHERE attrelid = to_regclass('{DOCUMENTS_TABLE}') \
1654               AND attname = 'embedding_vec' AND NOT attisdropped"
1655        ))
1656        .get_results::<WidthRow>(conn)
1657        .await
1658        .map_err(SearchError::backend)?;
1659
1660        Ok(rows
1661            .into_iter()
1662            .next()
1663            .and_then(|row| usize::try_from(row.width).ok()))
1664    }
1665
1666    /// Try to install `pgvector` and add the accelerated column + index.
1667    ///
1668    /// Returns `false` (rather than erroring) when the extension is not
1669    /// available: the portable array path is a complete implementation, so a
1670    /// managed Postgres without `pgvector` must boot, not crash.
1671    ///
1672    /// Takes the caller's connection: acquiring a second one while the caller
1673    /// holds the first deadlocks a pool of size 1 at boot.
1674    async fn try_enable_pgvector(
1675        &self,
1676        conn: &mut diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
1677        dimensions: usize,
1678    ) -> SearchResult<bool> {
1679        if diesel::sql_query("CREATE EXTENSION IF NOT EXISTS vector")
1680            .execute(&mut *conn)
1681            .await
1682            .is_err()
1683        {
1684            tracing::info!(
1685                "pgvector is not available; autumn-search will store embeddings as \
1686                 double precision[] and rank with autumn_search_cosine()"
1687            );
1688            return Ok(false);
1689        }
1690
1691        let present = diesel::sql_query(
1692            "SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') AS present",
1693        )
1694        .get_result::<ExistsRow>(&mut *conn)
1695        .await
1696        .map_err(SearchError::backend)?
1697        .present;
1698        if !present {
1699            return Ok(false);
1700        }
1701
1702        // `dimensions` is a `usize` from config, formatted by Rust — no caller
1703        // text reaches this statement.
1704        diesel::sql_query(format!(
1705            "ALTER TABLE {DOCUMENTS_TABLE} \
1706             ADD COLUMN IF NOT EXISTS embedding_vec vector({dimensions})"
1707        ))
1708        .execute(&mut *conn)
1709        .await
1710        .map_err(SearchError::backend)?;
1711
1712        // `ADD COLUMN IF NOT EXISTS` is a silent no-op against an existing
1713        // column of a DIFFERENT width, which would leave the store believing
1714        // it is in `PgVector{new}` while every insert fails with "expected
1715        // <old> dimensions". Verify what is actually there.
1716        let existing_width = Self::vector_column_width_of(conn)
1717            .await?
1718            .and_then(|w| i64::try_from(w).ok());
1719        if let Some(width) = existing_width
1720            && width > 0
1721            && usize::try_from(width).unwrap_or(0) != dimensions
1722        {
1723            tracing::warn!(
1724                configured = dimensions,
1725                existing = width,
1726                "autumn-search: the pgvector column has a different width than \
1727                 `search.embedding_dimensions`; falling back to the portable \
1728                 double precision[] path. Drop `autumn_search_documents.embedding_vec` \
1729                 and re-run a full backfill to adopt the new width."
1730            );
1731            return Ok(false);
1732        }
1733
1734        // Adopt embeddings written while the store was in Array mode —
1735        // otherwise every pre-existing document silently vanishes from k-NN
1736        // (the query filters `embedding_vec IS NOT NULL`) until a full
1737        // backfill happens to run. Rows written *after* this point stay in step
1738        // via `index()`, which keys on the physical column rather than on the
1739        // mode.
1740        diesel::sql_query(format!(
1741            "UPDATE {DOCUMENTS_TABLE} SET embedding_vec = embedding::text::vector \
1742             WHERE embedding IS NOT NULL AND embedding_vec IS NULL \
1743               AND array_length(embedding, 1) = {dimensions}"
1744        ))
1745        .execute(&mut *conn)
1746        .await
1747        .map_err(SearchError::backend)?;
1748
1749        // Best-effort: an ivfflat index needs training data and fails on an
1750        // empty table in some versions. Its absence costs speed, not
1751        // correctness, so never fail boot for it.
1752        let _ = diesel::sql_query(format!(
1753            "CREATE INDEX IF NOT EXISTS autumn_search_documents_embedding_vec_idx \
1754             ON {DOCUMENTS_TABLE} USING ivfflat (embedding_vec vector_cosine_ops) \
1755             WITH (lists = 100)"
1756        ))
1757        .execute(&mut *conn)
1758        .await;
1759
1760        Ok(true)
1761    }
1762}
1763
1764impl DocumentSource for PostgresSearchStore {
1765    fn fetch<'a>(
1766        &'a self,
1767        definition: &'a IndexDefinition,
1768        ids: &'a [i64],
1769    ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
1770        Box::pin(async move {
1771            require_postgres_runtime()?;
1772            checked(definition)?;
1773            if ids.is_empty() {
1774                return Ok(Vec::new());
1775            }
1776            self.load_documents(definition, Selection::Ids(ids)).await
1777        })
1778    }
1779
1780    fn scan<'a>(
1781        &'a self,
1782        definition: &'a IndexDefinition,
1783        after: Option<i64>,
1784        limit: usize,
1785    ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
1786        Box::pin(async move {
1787            require_postgres_runtime()?;
1788            checked(definition)?;
1789            self.load_documents(definition, Selection::After { after, limit })
1790                .await
1791        })
1792    }
1793}
1794
1795/// Which source rows to project.
1796enum Selection<'a> {
1797    /// Exactly these ids (a reindex).
1798    Ids(&'a [i64]),
1799    /// The next `limit` rows after `after`, by ascending id (a backfill).
1800    After { after: Option<i64>, limit: usize },
1801}
1802
1803impl PostgresSearchStore {
1804    /// The optional framework columns `definition`'s source table carries,
1805    /// cached per index.
1806    ///
1807    /// Resolved through `to_regclass`, which follows the connection's
1808    /// `search_path` exactly as the projection below does. An
1809    /// `information_schema` probe filtered on `current_schema()` would answer
1810    /// about a different table whenever the model lives in a non-first schema
1811    /// — silently blanking `tenant_id` and re-indexing soft-deleted rows.
1812    async fn optional_columns(
1813        &self,
1814        definition: &IndexDefinition,
1815        conn: &mut diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
1816    ) -> SearchResult<OptionalColumns> {
1817        if let Ok(cache) = self.optional_columns.read()
1818            && let Some(columns) = cache.get(definition.name)
1819        {
1820            return Ok(*columns);
1821        }
1822
1823        let row = diesel::sql_query(
1824            "SELECT bool_or(attname = 'tenant_id')  AS has_tenant, \
1825                    bool_or(attname = 'deleted_at') AS has_deleted_at \
1826             FROM pg_attribute \
1827             WHERE attrelid = to_regclass($1) AND attnum > 0 AND NOT attisdropped",
1828        )
1829        .bind::<Text, _>(definition.name.to_owned())
1830        .get_result::<OptionalColumnsRow>(conn)
1831        .await
1832        .map_err(SearchError::backend)?;
1833
1834        let columns = OptionalColumns {
1835            tenant_id: row.has_tenant.unwrap_or(false),
1836            deleted_at: row.has_deleted_at.unwrap_or(false),
1837        };
1838        if let Ok(mut cache) = self.optional_columns.write() {
1839            cache.insert(definition.name, columns);
1840        }
1841        Ok(columns)
1842    }
1843
1844    /// Project a model's table into `(id, fields json, tenant_id)`.
1845    ///
1846    /// Fully generic: the column list comes from the validated
1847    /// [`IndexDefinition`], so no per-model code is generated anywhere. The
1848    /// values are returned as one JSON object rather than N columns, which is
1849    /// what lets a single `QueryableByName` struct serve every model.
1850    ///
1851    /// The key column comes from [`IndexDefinition::key_column`], which
1852    /// `#[model]` fills in from the `#[id]` field — so a model keyed on
1853    /// `article_id` backfills as readily as one keyed on `id`. `validate()`
1854    /// has already checked it is a bare identifier; it is quoted here anyway,
1855    /// so a column named after a reserved word still works.
1856    async fn load_documents(
1857        &self,
1858        definition: &IndexDefinition,
1859        selection: Selection<'_>,
1860    ) -> SearchResult<Vec<SearchDocument>> {
1861        // ONE connection for both statements: acquiring a second while holding
1862        // the first deadlocks a small pool.
1863        let mut conn = self.conn().await?;
1864        let columns = self.optional_columns(definition, &mut conn).await?;
1865        let key = format!("\"{}\"", definition.key_column);
1866
1867        let mut binds: Vec<Bound> = Vec::new();
1868        let mut param = 1_usize;
1869        let mut predicates: Vec<String> = Vec::new();
1870        let mut limit_clause = String::new();
1871
1872        match selection {
1873            Selection::Ids(ids) => {
1874                predicates.push(format!("{key} = ANY(${param})"));
1875                binds.push(Bound::Ids(ids.to_vec()));
1876            }
1877            Selection::After { after, limit } => {
1878                if let Some(after) = after {
1879                    predicates.push(format!("{key} > ${param}"));
1880                    binds.push(Bound::BigInt(after));
1881                    param = param.saturating_add(1);
1882                }
1883                limit_clause = format!(" LIMIT ${param}");
1884                binds.push(Bound::BigInt(i64::try_from(limit).unwrap_or(i64::MAX)));
1885            }
1886        }
1887        // Both conditions: the column has to exist, and the model's repository has to
1888        // actually be `soft_delete`.
1889        //
1890        // Column presence alone is not the question. A `deleted_at` that is audit
1891        // history — a supported shape, whose finders return those rows — would
1892        // otherwise be read as a tombstone here: reindex would see the row as absent
1893        // and delete its document, and a purging backfill would drop it entirely. The
1894        // index would hide records the app still shows.
1895        //
1896        // When the repository is `soft_delete`, the filter is required for the
1897        // mirror-image reason: `after_delete_commit` removes the document, and a later
1898        // backfill would put it straight back — searchable while `find` hides it.
1899        if columns.deleted_at && definition.soft_delete {
1900            predicates.push("deleted_at IS NULL".to_owned());
1901        }
1902        let where_clause = if predicates.is_empty() {
1903            String::new()
1904        } else {
1905            format!("WHERE {}", predicates.join(" AND "))
1906        };
1907
1908        let tenant = if columns.tenant_id {
1909            "tenant_id::text"
1910        } else {
1911            "NULL::text"
1912        };
1913
1914        // `::bigint`: the row is decoded as `BigInt`, so an `integer`/`serial`
1915        // key column would be a runtime deserialization error the compiler
1916        // cannot see. Aliased to `id` because that is what `SourceRow` names.
1917        let rows = bind_all(
1918            diesel::sql_query(format!(
1919                "SELECT {key}::bigint AS id, {} AS fields, {tenant} AS tenant_id \
1920                 FROM \"{}\" {where_clause} ORDER BY {key} ASC{limit_clause}",
1921                fields_projection(definition),
1922                definition.name,
1923            ))
1924            .into_boxed::<autumn_web::RuntimeBackend>(),
1925            binds,
1926        )
1927        .load::<SourceRow>(&mut conn)
1928        .await
1929        .map_err(SearchError::backend)?;
1930
1931        let mut documents = Vec::with_capacity(rows.len());
1932        for row in rows {
1933            let values: std::collections::BTreeMap<String, String> =
1934                serde_json::from_str(&row.fields).map_err(SearchError::backend)?;
1935            let mut document = SearchDocument::new(definition.name, row.id);
1936            for field in definition.fields.iter() {
1937                let value = values.get(field.name).cloned().unwrap_or_default();
1938                document = document.with_field(field.name, field.weight, value);
1939            }
1940            if let Some(embed) = definition.embed_field
1941                && let Some(text) = values.get(embed)
1942                && !text.is_empty()
1943            {
1944                document.embed_text = Some(text.clone());
1945            }
1946            document.tenant_id = row.tenant_id;
1947            documents.push(document);
1948        }
1949        Ok(documents)
1950    }
1951}
1952
1953/// The `jsonb_build_object(...)` projection for `definition`'s fields.
1954///
1955/// Chunked and concatenated with `||`: `jsonb_build_object` is variadic-"any"
1956/// and Postgres caps a function call at **100 arguments**, so a model with
1957/// more than 50 searchable fields would otherwise be a hard
1958/// `cannot pass more than 100 arguments to a function` error.
1959fn fields_projection(definition: &IndexDefinition) -> String {
1960    /// 40 pairs = 80 arguments, comfortably under the cap.
1961    const PAIRS_PER_CHUNK: usize = 40;
1962
1963    let pairs: Vec<String> = definition
1964        .fields
1965        .iter()
1966        .map(|field| format!("'{0}', COALESCE(\"{0}\"::text, '')", field.name))
1967        .collect();
1968    if pairs.is_empty() {
1969        return "'{}'::jsonb::text".to_owned();
1970    }
1971    let merged = pairs
1972        .chunks(PAIRS_PER_CHUNK)
1973        .map(|chunk| format!("jsonb_build_object({})", chunk.join(", ")))
1974        .collect::<Vec<_>>()
1975        .join(" || ");
1976    // Parenthesized before the cast: `::` binds TIGHTER than `||`, so
1977    // `a || b::text` casts only the final chunk and then asks Postgres for
1978    // `jsonb || text` — which is not a jsonb merge. The result is either an
1979    // operator error or two objects concatenated into invalid JSON that
1980    // `load_documents` then fails to deserialize. Only reachable past the
1981    // chunk boundary, which is exactly the case the chunking exists to serve.
1982    format!("({merged})::text")
1983}
1984
1985/// The `fields` JSON one document is stored with.
1986fn fields_json(document: &IndexedDocument) -> SearchResult<String> {
1987    serde_json::to_string(
1988        &document
1989            .document
1990            .fields
1991            .iter()
1992            .map(|f| (f.name, f.value.clone()))
1993            .collect::<std::collections::BTreeMap<_, _>>(),
1994    )
1995    .map_err(SearchError::backend)
1996}
1997
1998/// DDL for the framework-owned index, applied idempotently on every boot.
1999///
2000/// Not a `diesel_migrations` set: the table is plugin-owned infrastructure,
2001/// not application schema, and every statement is `IF NOT EXISTS`, so running
2002/// it on each boot is both safe and self-repairing.
2003const SCHEMA_STATEMENTS: &[&str] = &[
2004    // Durable evidence that a record was deleted, and WHEN.
2005    //
2006    // A hard delete leaves no trace, so a backfill batch scanned before the
2007    // delete would insert the record straight back — the `ON CONFLICT` guard
2008    // cannot help, because with the row gone there is no conflict to guard.
2009    // The ledger is what a conditional insert consults instead. Rows are
2010    // pruned once they are older than any backfill could plausibly be.
2011    "CREATE TABLE IF NOT EXISTS autumn_search_deletes (
2012        index_name TEXT        NOT NULL,
2013        record_id  BIGINT      NOT NULL,
2014        deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2015        PRIMARY KEY (index_name, record_id)
2016    )",
2017    "CREATE INDEX IF NOT EXISTS autumn_search_deletes_pruning_idx
2018        ON autumn_search_deletes (deleted_at)",
2019    "CREATE TABLE IF NOT EXISTS autumn_search_documents (
2020        index_name    TEXT        NOT NULL,
2021        record_id     BIGINT      NOT NULL,
2022        tenant_id     TEXT,
2023        language      TEXT        NOT NULL DEFAULT 'simple',
2024        fields        JSONB       NOT NULL DEFAULT '{}'::jsonb,
2025        content       TEXT        NOT NULL DEFAULT '',
2026        search_vector TSVECTOR    NOT NULL DEFAULT to_tsvector('simple', ''),
2027        embedding     DOUBLE PRECISION[],
2028        updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2029        PRIMARY KEY (index_name, record_id)
2030    )",
2031    "CREATE INDEX IF NOT EXISTS autumn_search_documents_vector_idx
2032        ON autumn_search_documents USING GIN (search_vector)",
2033    "CREATE INDEX IF NOT EXISTS autumn_search_documents_tenant_idx
2034        ON autumn_search_documents (index_name, tenant_id)",
2035    // Portable cosine similarity for the non-pgvector path. `unnest(a, b)`
2036    // walks both arrays in lockstep; a zero-norm vector yields 0 rather than a
2037    // division by zero.
2038    "CREATE OR REPLACE FUNCTION autumn_search_cosine(a DOUBLE PRECISION[], b DOUBLE PRECISION[])
2039     RETURNS DOUBLE PRECISION LANGUAGE sql IMMUTABLE AS $$
2040        SELECT COALESCE(
2041            SUM(t.x * t.y) / NULLIF(SQRT(SUM(t.x * t.x)) * SQRT(SUM(t.y * t.y)), 0),
2042            0
2043        )
2044        FROM unnest(a, b) AS t(x, y)
2045     $$",
2046];
2047
2048#[cfg(test)]
2049mod tests {
2050    use autumn_web::search::SearchIndexField;
2051
2052    use super::*;
2053
2054    const FIELDS: &[SearchIndexField] = &[
2055        SearchIndexField::new("title", 'A'),
2056        SearchIndexField::new("body", 'B'),
2057    ];
2058
2059    // An app can use the store without the plugin. On a SQLite build, every
2060    // query is refused before it reaches the pool (#2539 §5).
2061    #[test]
2062    fn the_store_needs_the_postgres_backend() {
2063        let result = require_postgres_runtime();
2064        autumn_web::backend_select! {
2065            pg => {
2066                assert!(result.is_ok(), "{result:?}");
2067            },
2068            sqlite => {
2069                let message = result.expect_err("refused on SQLite").to_string();
2070                assert!(message.contains("SQLite"), "{message}");
2071            },
2072        }
2073    }
2074
2075    fn definition() -> IndexDefinition {
2076        IndexDefinition::new("articles", "english", FIELDS, Some("body"), false)
2077    }
2078
2079    // A short-circuit (an empty id list here) must not hide the backend
2080    // mismatch on SQLite (#2539 review).
2081    #[tokio::test]
2082    async fn an_early_return_does_not_skip_the_backend_check() {
2083        let store = PostgresSearchStore::new(None);
2084        let result = store.delete(&definition(), &[]).await;
2085        autumn_web::backend_select! {
2086            pg => {
2087                assert!(result.is_ok(), "{result:?}");
2088            },
2089            sqlite => {
2090                let message = result.expect_err("refused on SQLite").to_string();
2091                assert!(message.contains("SQLite"), "{message}");
2092            },
2093        }
2094    }
2095
2096    fn doc(id: i64) -> IndexedDocument {
2097        IndexedDocument::new(autumn_web::search::SearchDocument::new("articles", id))
2098    }
2099
2100    #[test]
2101    fn unconditional_dedup_keeps_the_last_occurrence() {
2102        // The unconditional (`index`) form ran N sequential UPSERTs with no
2103        // guard, so a later occurrence's write is what actually persists —
2104        // keep the LAST, to match.
2105        let documents = vec![doc(1), doc(2), doc(1), doc(3), doc(2)];
2106        let kept: Vec<i64> = dedupe_by_id(&documents, false)
2107            .into_iter()
2108            .map(IndexedDocument::id)
2109            .collect();
2110        // documents[2] (id=1) and documents[4] (id=2) are the LAST
2111        // occurrences of their ids; documents[3] (id=3) is unique.
2112        // Ascending original-index order: 1, 3, 2.
2113        assert_eq!(kept, vec![1, 3, 2], "{kept:?}");
2114    }
2115
2116    #[test]
2117    fn watermark_guarded_dedup_keeps_the_first_occurrence() {
2118        // The old per-document loop's FIRST occurrence sets
2119        // `updated_at = NOW()`, which is newer than `watermark` — so every
2120        // LATER occurrence's own `WHERE updated_at <= watermark` guard fails
2121        // against that just-written row and silently no-ops. Keep the
2122        // FIRST, to match.
2123        let documents = vec![doc(1), doc(2), doc(1), doc(3), doc(2)];
2124        let kept: Vec<i64> = dedupe_by_id(&documents, true)
2125            .into_iter()
2126            .map(IndexedDocument::id)
2127            .collect();
2128        // documents[0] (id=1) and documents[1] (id=2) are the FIRST
2129        // occurrences of their ids; documents[3] (id=3) is unique.
2130        // Ascending original-index order: 1, 2, 3.
2131        assert_eq!(kept, vec![1, 2, 3], "{kept:?}");
2132    }
2133
2134    #[test]
2135    fn no_duplicates_keeps_every_index_in_order_either_direction() {
2136        let documents = vec![doc(10), doc(20), doc(30)];
2137        for keep_first in [true, false] {
2138            let kept: Vec<i64> = dedupe_by_id(&documents, keep_first)
2139                .into_iter()
2140                .map(IndexedDocument::id)
2141                .collect();
2142            assert_eq!(kept, vec![10, 20, 30], "keep_first={keep_first}");
2143        }
2144    }
2145
2146    #[test]
2147    fn deduplicated_documents_sort_by_record_id_regardless_of_input_order() {
2148        // The mechanism write_documents relies on to prevent a cross-batch
2149        // deadlock: two callers indexing the same ids in different orders
2150        // (e.g. `[1, 2]` and `[2, 1]`) must both end up locking conflict
2151        // rows in the SAME order, or they can lock-and-wait on each other.
2152        // A canonical (ascending) order across every caller is what makes
2153        // that impossible — verified here for both dedup directions and
2154        // several input orderings.
2155        for keep_first in [true, false] {
2156            for documents in [
2157                vec![doc(2), doc(1), doc(3)],
2158                vec![doc(3), doc(1), doc(2)],
2159                vec![doc(1), doc(3), doc(2)],
2160            ] {
2161                let mut kept: Vec<&IndexedDocument> = dedupe_by_id(&documents, keep_first);
2162                kept.sort_unstable_by_key(|document| document.id());
2163                let ids: Vec<i64> = kept.into_iter().map(IndexedDocument::id).collect();
2164                assert_eq!(
2165                    ids,
2166                    vec![1, 2, 3],
2167                    "keep_first={keep_first} input={documents:?}"
2168                );
2169            }
2170        }
2171    }
2172
2173    fn doc_with_embedding(id: i64, width: usize) -> IndexedDocument {
2174        doc(id).with_embedding(vec![0.0; width])
2175    }
2176
2177    const PGVECTOR_MODE: Option<VectorMode> = Some(VectorMode::PgVector { dimensions: 4 });
2178
2179    fn assert_dimension_mismatch(error: &SearchError, expected: usize, actual: usize) {
2180        assert!(
2181            matches!(
2182                error,
2183                SearchError::DimensionMismatch {
2184                    expected: exp,
2185                    actual: act
2186                } if *exp == expected && *act == actual
2187            ),
2188            "{error:?}"
2189        );
2190    }
2191
2192    #[test]
2193    fn embedding_widths_are_validated_before_unconditional_dedup_can_hide_a_bad_duplicate() {
2194        // #2311: the issue's exact scenario. Unconditional (`index`) dedup
2195        // keeps the LAST occurrence, so the malformed first entry never
2196        // reaches the row loop's width check — without pre-dedup validation
2197        // this batch succeeds and the malformed embedder output is masked.
2198        let documents = vec![
2199            doc_with_embedding(1, 3), // bad width, loses the coin flip
2200            doc_with_embedding(1, 4), // valid, survives dedup
2201        ];
2202        let error = validate_embedding_widths(&documents, Some(4), PGVECTOR_MODE)
2203            .expect_err("a wrong-width embedding must be rejected");
2204        assert_dimension_mismatch(&error, 4, 3);
2205    }
2206
2207    #[test]
2208    fn embedding_widths_are_validated_before_guarded_dedup_can_hide_a_bad_duplicate() {
2209        // Watermark-guarded (`index_unless_newer`) dedup keeps the FIRST
2210        // occurrence, so a malformed LATER duplicate is the one that would
2211        // be silently discarded.
2212        let documents = vec![
2213            doc_with_embedding(1, 4), // valid, survives dedup
2214            doc_with_embedding(1, 3), // bad width, loses the coin flip
2215        ];
2216        let error = validate_embedding_widths(&documents, Some(4), PGVECTOR_MODE)
2217            .expect_err("a wrong-width embedding must be rejected");
2218        assert_dimension_mismatch(&error, 4, 3);
2219    }
2220
2221    #[test]
2222    fn embedding_width_validation_accepts_a_clean_batch() {
2223        let documents = vec![
2224            doc_with_embedding(1, 4),
2225            doc_with_embedding(2, 4),
2226            doc(3), // no embedding at all is fine
2227        ];
2228        validate_embedding_widths(&documents, Some(4), PGVECTOR_MODE)
2229            .expect("a clean batch must validate");
2230    }
2231
2232    #[test]
2233    fn embedding_width_validation_only_rejects_in_pgvector_mode() {
2234        // The portable/`Array` mode does not reject a width mismatch: the
2235        // row loop repairs a stale `embedding_vec` copy from a previous
2236        // width by NULLing it, so pre-dedup validation must not change that
2237        // behavior.
2238        let documents = vec![doc_with_embedding(1, 3)];
2239        validate_embedding_widths(&documents, Some(4), Some(VectorMode::Array))
2240            .expect("non-pgvector mode must not reject a width mismatch");
2241    }
2242
2243    #[test]
2244    fn embedding_width_validation_skips_when_there_is_no_physical_column() {
2245        // `None` width means the `embedding_vec` column is absent and must
2246        // stay out of the write entirely — nothing to validate against.
2247        let documents = vec![doc_with_embedding(1, 3)];
2248        validate_embedding_widths(&documents, None, PGVECTOR_MODE)
2249            .expect("no physical column means no validation");
2250    }
2251
2252    #[test]
2253    fn greedy_chunking_never_lets_a_statement_approach_the_bind_limit_even_with_repeated_fields() {
2254        // Mirrors write_documents' own greedy accounting: `SearchDocument::fields`
2255        // is a public, uncapped `Vec` — a hand-built document can carry the
2256        // SAME declared field many times over, each contributing its own
2257        // bind. A per-document estimate based on `definition.fields.len()`
2258        // (one declared field, one possible value) would undercount this;
2259        // the real code counts each document's ACTUAL matching-field
2260        // entries instead. Reproduces the reported case: many documents
2261        // each carrying far more copies of one field than the index
2262        // declares.
2263        const PARAM_BUDGET: usize = 60_000;
2264        let weight_of = |name: &str| if name == "title" { Some('A') } else { None };
2265
2266        // 800 documents, each with the SAME declared field repeated 90
2267        // times: 5 fixed + 90 = 95 binds/document, 800 * 95 = 76,000 total —
2268        // over PARAM_BUDGET, so this MUST split into more than one chunk.
2269        let mut documents = Vec::new();
2270        for id in 0..800i64 {
2271            let mut document = autumn_web::search::SearchDocument::new("articles", id);
2272            for _ in 0..90 {
2273                document = document.with_field("title", 'A', "x");
2274            }
2275            documents.push(IndexedDocument::new(document));
2276        }
2277
2278        let mut chunks: Vec<usize> = Vec::new();
2279        let mut next_param = 3usize; // $1 index_name, $2 language (no watermark)
2280        let mut chunk_len = 0usize;
2281        for document in &documents {
2282            let doc_param_count = 5usize.saturating_add(
2283                document
2284                    .document
2285                    .fields
2286                    .iter()
2287                    .filter(|field| weight_of(field.name).is_some())
2288                    .count(),
2289            );
2290            if chunk_len > 0 && next_param.saturating_add(doc_param_count) > PARAM_BUDGET {
2291                chunks.push(next_param);
2292                next_param = 3;
2293                chunk_len = 0;
2294            }
2295            next_param = next_param.saturating_add(doc_param_count);
2296            chunk_len = chunk_len.saturating_add(1);
2297        }
2298        chunks.push(next_param);
2299
2300        assert!(
2301            chunks.len() > 1,
2302            "the fixture must force more than one chunk: {chunks:?}"
2303        );
2304        for &total in &chunks {
2305            assert!(
2306                total < 65_535,
2307                "a chunk's total bind count {total} must stay under Postgres' bind limit: \
2308                 {chunks:?}"
2309            );
2310        }
2311    }
2312
2313    #[test]
2314    fn a_row_count_cap_bounds_chunk_size_even_when_the_param_budget_never_would() {
2315        // A document with NO weighted fields binds only 5 params — the
2316        // param budget alone would admit 60_000 / 5 = 12,000 rows into one
2317        // chunk. The watermark-guarded shape joins rows with `UNION ALL`,
2318        // and a chain that deep risks Postgres' `max_stack_depth` during
2319        // parsing even while comfortably under the bind limit — so a
2320        // SEPARATE row-count cap has to apply regardless of how cheap each
2321        // row's params are.
2322        const PARAM_BUDGET: usize = 60_000;
2323        const MAX_ROWS_PER_CHUNK: usize = 1_000;
2324        let doc_param_count = 5usize; // no weighted fields on any document
2325
2326        let mut chunks: Vec<usize> = Vec::new();
2327        let mut next_param = 3usize;
2328        let mut chunk_len = 0usize;
2329        for _ in 0..5_000 {
2330            if chunk_len > 0
2331                && (next_param.saturating_add(doc_param_count) > PARAM_BUDGET
2332                    || chunk_len >= MAX_ROWS_PER_CHUNK)
2333            {
2334                chunks.push(chunk_len);
2335                next_param = 3;
2336                chunk_len = 0;
2337            }
2338            next_param = next_param.saturating_add(doc_param_count);
2339            chunk_len = chunk_len.saturating_add(1);
2340        }
2341        chunks.push(chunk_len);
2342
2343        assert!(
2344            chunks.len() > 1,
2345            "5,000 minimal-field documents must still split into more than one chunk: {chunks:?}"
2346        );
2347        for &len in &chunks {
2348            assert!(
2349                len <= MAX_ROWS_PER_CHUNK,
2350                "a chunk of {len} rows must never exceed MAX_ROWS_PER_CHUNK: {chunks:?}"
2351            );
2352        }
2353    }
2354
2355    #[test]
2356    fn a_filtered_vector_query_does_not_let_ivfflat_decide_what_is_visible() {
2357        // Unfiltered: order by DISTANCE so ivfflat can serve it. That form is
2358        // the whole reason the fast path is worth having.
2359        let open = pgvector_order_by(false);
2360        assert!(open.contains("<=>"), "{open}");
2361        assert!(open.contains("ASC, record_id ASC"), "{open}");
2362
2363        // Filtered: order by the DERIVED similarity, which the planner cannot
2364        // serve from ivfflat, so the scan is exact. ivfflat probes lists
2365        // before the WHERE clause runs, so a selective authorization filter
2366        // can otherwise return short or empty while authorized neighbours sit
2367        // in unprobed lists.
2368        let scoped = pgvector_order_by(true);
2369        assert!(
2370            scoped.starts_with("ORDER BY (1 - "),
2371            "a filtered query must not be served by the approximate index: {scoped}"
2372        );
2373        assert_ne!(open, scoped);
2374    }
2375
2376    #[test]
2377    fn the_pgvector_predicate_excludes_nan_scores_before_limit() {
2378        // Zero-norm stored vectors make pgvector's `<=>` return NaN, which
2379        // sorts FIRST under `DESC` — on a filtered query they would consume
2380        // `LIMIT` slots ahead of valid neighbours, and the Rust-side
2381        // `is_finite` filter runs too late to give those slots back (#2313).
2382        // NaN is not equal to itself, so the predicate's self-equality test
2383        // on the distance drops exactly the NaN rows, before ordering and
2384        // limiting.
2385        let predicate = pgvector_embedding_predicate();
2386        assert!(
2387            predicate.contains("embedding_vec IS NOT NULL"),
2388            "{predicate}"
2389        );
2390        assert!(
2391            predicate.contains("(embedding_vec <=> $2::vector) = (embedding_vec <=> $2::vector)"),
2392            "NaN must be excluded by self-inequality: {predicate}"
2393        );
2394    }
2395
2396    #[test]
2397    fn an_unconditional_upsert_clears_the_tombstone_in_the_same_statement() {
2398        // Two statements let a delete interleave: the upsert commits, the
2399        // delete commits (document removed, tombstone written), and the
2400        // trailing clear then erases that NEWER tombstone while the document
2401        // stays absent — leaving a backfill able to see neither and resurrect
2402        // the record. One statement admits only the two consistent
2403        // serializations.
2404        let row = || UpsertRow {
2405            columns: "$1, $4, $5, $2, $6::jsonb, $7, to_tsvector('simple', ''), \
2406                      $8::double precision[]"
2407                .to_owned(),
2408            record_id_param: "$4".to_owned(),
2409        };
2410        let sql = upsert_sql(&[row()], "", "", 9, None);
2411        assert!(
2412            sql.starts_with("WITH upserted AS ("),
2413            "the upsert and the tombstone clear must be one statement: {sql}"
2414        );
2415        assert!(
2416            sql.contains(&format!("DELETE FROM {DELETES_TABLE}")),
2417            "{sql}"
2418        );
2419        assert!(sql.contains("record_id = ANY($9)"), "{sql}");
2420
2421        // The tombstone clear must lock the ledger rows in ascending
2422        // `record_id` order — ORDER BY + FOR UPDATE is the only thing that
2423        // controls Postgres's lock acquisition order — matching `delete()`'s
2424        // `INSERT ... unnest($2)` order. A bare DELETE ... ANY($n) locks in
2425        // scan order and deadlocks against an oppositely-ordered delete
2426        // racing over the same tombstones (#2310).
2427        let lock_block = sql
2428            .find(&format!("DELETE FROM {DELETES_TABLE}"))
2429            .map(|at| &sql[..at])
2430            .expect("the tombstone-clear DELETE exists");
2431        assert!(lock_block.contains("ORDER BY record_id"), "{sql}");
2432        assert!(lock_block.contains("FOR UPDATE"), "{sql}");
2433
2434        // A watermarked (backfill) write leaves the ledger alone — its batch is
2435        // older than any tombstone by construction. Clearing it there would
2436        // undo the very delete the guard is protecting.
2437        let guarded = upsert_sql(&[row()], "", "", 9, Some(3));
2438        assert!(
2439            !guarded.contains(&format!("DELETE FROM {DELETES_TABLE}")),
2440            "a backfill write must never clear a tombstone: {guarded}"
2441        );
2442        assert!(
2443            guarded.contains(&format!("SELECT 1 FROM {DELETES_TABLE}")),
2444            "but it must still be blocked by one: {guarded}"
2445        );
2446
2447        // Two rows, guarded: joined by UNION ALL, each with its own
2448        // `record_id` in its own tombstone check, sharing the SAME watermark
2449        // slot rather than binding it twice.
2450        let second = UpsertRow {
2451            columns: "$1, $9, $10, $2, $11::jsonb, $12, to_tsvector('simple', ''), \
2452                      $13::double precision[]"
2453                .to_owned(),
2454            record_id_param: "$9".to_owned(),
2455        };
2456        let multi = upsert_sql(&[row(), second], "", "", 99, Some(3));
2457        assert_eq!(multi.matches(" UNION ALL ").count(), 1, "{multi}");
2458        assert_eq!(multi.matches("$3::timestamptz").count(), 3, "{multi}");
2459        assert!(multi.contains("record_id = $4"), "{multi}");
2460        assert!(multi.contains("record_id = $9"), "{multi}");
2461    }
2462
2463    #[test]
2464    fn delete_binds_its_ledger_ids_in_ascending_order() {
2465        // `delete()`'s `INSERT ... unnest($2)` acquires tombstone rows in
2466        // array order, so the bound array must be ascending to match the
2467        // unconditional batch's `ORDER BY record_id FOR UPDATE` tombstone
2468        // clear — opposite orders on the two paths deadlock (#2310).
2469        assert_eq!(ascending_ids(&[3, 1, 2]), vec![1, 2, 3]);
2470        assert_eq!(ascending_ids(&[1, 2, 3]), vec![1, 2, 3]);
2471        assert_eq!(ascending_ids(&[5, 5, 1]), vec![1, 5, 5]);
2472        assert_eq!(ascending_ids(&[]), Vec::<i64>::new());
2473    }
2474
2475    #[test]
2476    fn a_min_score_is_bound_so_the_statement_cache_stays_bounded() {
2477        // The property that matters is not "the value is correct" but "the SQL
2478        // TEXT does not vary with the value" — diesel's statement cache is
2479        // keyed on that text and never evicts, so a request-controlled
2480        // threshold would otherwise leak one permanent prepared statement per
2481        // distinct value.
2482        let expr = "(1 - (embedding_vec <=> $2::vector))::double precision";
2483        let mut a = 5;
2484        let mut b = 5;
2485        let first = score_threshold_sql(expr, Some(0.25), &mut a);
2486        let second = score_threshold_sql(expr, Some(0.999_999), &mut b);
2487        assert_eq!(
2488            first, second,
2489            "two thresholds must produce ONE statement, not two"
2490        );
2491        assert_eq!(a, b, "and consume the same parameter slot");
2492        assert!(first.contains("$5"), "{first}");
2493        assert!(
2494            !first.contains("0.25"),
2495            "the value must not reach the SQL text: {first}"
2496        );
2497
2498        // No bound: no fragment, no parameter consumed.
2499        let mut param = 5;
2500        assert!(score_threshold_sql(expr, None, &mut param).is_empty());
2501        assert_eq!(param, 5);
2502
2503        // Non-finite: dropped rather than bound. `>= NaN` matches nothing.
2504        let mut param = 5;
2505        assert!(score_threshold_sql(expr, Some(f32::NAN), &mut param).is_empty());
2506        assert!(score_threshold_sql(expr, Some(f32::INFINITY), &mut param).is_empty());
2507        assert_eq!(param, 5);
2508
2509        // The fragment and its bind must agree on whether a slot was used —
2510        // a disagreement would shift every later parameter by one.
2511        for candidate in [None, Some(0.5), Some(f32::NAN), Some(f32::INFINITY)] {
2512            let mut param = 5;
2513            let emitted = !score_threshold_sql(expr, candidate, &mut param).is_empty();
2514            assert_eq!(
2515                emitted,
2516                score_threshold_bind(candidate).is_some(),
2517                "fragment/bind disagreement for {candidate:?}"
2518            );
2519            assert_eq!(param, if emitted { 6 } else { 5 });
2520        }
2521    }
2522
2523    #[test]
2524    fn ts_rank_weights_track_the_framework_factors() {
2525        // Postgres' own default is `{0.1, 0.2, 0.4, 1.0}`; the framework's
2526        // contract normalizes to `{0.1, 0.2, 0.5, 1.0}`. `B` is the one that
2527        // differs, which is precisely the weight `#[searchable(weight = "B")]`
2528        // gives a body field — so leaving it defaulted makes Postgres rank
2529        // differently from `MemorySearchBackend` on the most common model
2530        // shape there is.
2531        use autumn_web::search::weight_factor;
2532
2533        // Compared numerically, not as text: a string comparison would depend
2534        // on float formatting (`1.0` renders as `1`) and a fixed-precision
2535        // format would hide a genuine drift in a small ratio.
2536        let declared = parse_vector(
2537            TS_RANK_WEIGHTS
2538                .trim_end_matches("::float4[]")
2539                .trim_matches('\''),
2540        );
2541        let top = weight_factor('A');
2542        let expected: Vec<f32> = ['D', 'C', 'B', 'A']
2543            .into_iter()
2544            .map(|weight| weight_factor(weight) / top)
2545            .collect();
2546        assert_eq!(
2547            declared.len(),
2548            expected.len(),
2549            "ts_rank_cd takes exactly {{D, C, B, A}}: {TS_RANK_WEIGHTS}"
2550        );
2551        for (index, (got, want)) in declared.iter().zip(&expected).enumerate() {
2552            assert!(
2553                (got - want).abs() < 1e-6,
2554                "weight {} is {got}, expected {want} — the SQL array must stay in step with \
2555                 `weight_factor`",
2556                ['D', 'C', 'B', 'A'][index]
2557            );
2558        }
2559    }
2560
2561    #[test]
2562    fn array_literals_round_trip() {
2563        let vector = vec![1.0_f32, -0.5, 0.25];
2564        assert_eq!(parse_vector(&array_literal(&vector)), vector);
2565        assert_eq!(parse_vector(&vector_literal(&vector)), vector);
2566    }
2567
2568    #[test]
2569    fn non_finite_values_never_produce_invalid_sql() {
2570        let literal = array_literal(&[f32::NAN, f32::INFINITY, 1.0]);
2571        assert_eq!(literal, "{0,0,1}");
2572        assert!(!literal.contains("NaN"));
2573        assert!(!literal.contains("inf"));
2574    }
2575
2576    #[test]
2577    fn parsing_tolerates_empty_and_malformed_elements() {
2578        assert!(parse_vector("{}").is_empty());
2579        assert!(parse_vector("[]").is_empty());
2580        assert_eq!(parse_vector("{1, ,2}"), vec![1.0, 2.0]);
2581        assert_eq!(parse_vector("{1,oops,2}"), vec![1.0, 2.0]);
2582    }
2583
2584    #[test]
2585    fn an_empty_filter_adds_no_predicate() {
2586        let mut param = 2;
2587        let mut binds = Vec::new();
2588        assert_eq!(
2589            filter_sql(
2590                &definition(),
2591                &SearchFilter::default(),
2592                &mut param,
2593                &mut binds
2594            ),
2595            ""
2596        );
2597        assert_eq!(param, 2);
2598        assert!(binds.is_empty());
2599    }
2600
2601    #[test]
2602    fn a_tenant_filter_binds_its_value_rather_than_interpolating_it() {
2603        let mut param = 2;
2604        let mut binds = Vec::new();
2605        let sql = filter_sql(
2606            &definition(),
2607            &SearchFilter::default().tenant("acme'; DROP TABLE users; --"),
2608            &mut param,
2609            &mut binds,
2610        );
2611        assert_eq!(sql, " AND tenant_id = $2");
2612        assert_eq!(
2613            binds,
2614            vec![Bound::Text("acme'; DROP TABLE users; --".to_owned())]
2615        );
2616        assert_eq!(param, 3);
2617    }
2618
2619    #[test]
2620    fn an_empty_allowlist_renders_a_fail_closed_predicate() {
2621        let mut param = 2;
2622        let mut binds = Vec::new();
2623        let sql = filter_sql(
2624            &definition(),
2625            &SearchFilter::default().allow_ids(Vec::<i64>::new()),
2626            &mut param,
2627            &mut binds,
2628        );
2629        assert!(sql.contains("AND FALSE"), "{sql}");
2630    }
2631
2632    #[test]
2633    fn id_lists_are_bound_as_arrays_never_interpolated() {
2634        let mut param = 2;
2635        let mut binds = Vec::new();
2636        let sql = filter_sql(
2637            &definition(),
2638            &SearchFilter::default()
2639                .allow_ids([1_i64, 2])
2640                .exclude_ids([3_i64]),
2641            &mut param,
2642            &mut binds,
2643        );
2644        // Bound arrays, not interpolated `IN (…)` lists: the literal form
2645        // would mint a distinct (never-evicted) prepared statement for every
2646        // distinct id set a caller happens to filter on.
2647        assert!(sql.contains("record_id = ANY($2)"), "{sql}");
2648        assert!(sql.contains("NOT (record_id = ANY($3))"), "{sql}");
2649        assert!(
2650            !sql.contains("IN ("),
2651            "ids must never be interpolated: {sql}"
2652        );
2653        assert_eq!(binds, vec![Bound::Ids(vec![1, 2]), Bound::Ids(vec![3])]);
2654    }
2655
2656    #[test]
2657    fn equals_predicates_bind_values_and_number_parameters_in_order() {
2658        let mut param = 4;
2659        let mut binds = Vec::new();
2660        let sql = filter_sql(
2661            &definition(),
2662            &SearchFilter::default()
2663                .equals("title", "Hello")
2664                .equals("body", "World"),
2665            &mut param,
2666            &mut binds,
2667        );
2668        // BTreeMap iterates in key order: body, then title.
2669        assert!(sql.contains("fields ->> 'body' = $4"), "{sql}");
2670        assert!(sql.contains("fields ->> 'title' = $5"), "{sql}");
2671        assert_eq!(
2672            binds,
2673            vec![
2674                Bound::Text("World".to_owned()),
2675                Bound::Text("Hello".to_owned())
2676            ]
2677        );
2678        assert_eq!(param, 6);
2679    }
2680
2681    #[test]
2682    fn an_undeclared_equals_key_never_reaches_sql() {
2683        // A field name is an IDENTIFIER position. Left unchecked, a
2684        // request-supplied facet key like `title' = 'x' OR '1'='1` would close
2685        // the quote and OR away the tenant and allowlist predicates that
2686        // precede it — widening past the visibility filter, not merely
2687        // injecting. It must fail closed instead.
2688        let mut param = 4;
2689        let mut binds = Vec::new();
2690        let sql = filter_sql(
2691            &definition(),
2692            &SearchFilter::default().equals("title' = 'x' OR '1'='1", "boom"),
2693            &mut param,
2694            &mut binds,
2695        );
2696
2697        assert_eq!(sql, " AND FALSE", "{sql}");
2698        assert!(!sql.contains("OR"), "{sql}");
2699        assert!(binds.is_empty(), "nothing may be bound for a dropped key");
2700        assert_eq!(param, 4, "the parameter counter must not advance");
2701    }
2702
2703    #[test]
2704    fn an_undeclared_key_does_not_disturb_the_numbering_of_declared_ones() {
2705        let mut param = 4;
2706        let mut binds = Vec::new();
2707        let sql = filter_sql(
2708            &definition(),
2709            &SearchFilter::default()
2710                .equals("aaa_not_a_field", "x")
2711                .equals("title", "Hello"),
2712            &mut param,
2713            &mut binds,
2714        );
2715
2716        // BTreeMap order puts the bogus key first; the real one must still
2717        // bind at $4.
2718        assert!(sql.contains("fields ->> 'title' = $4"), "{sql}");
2719        assert!(sql.contains("AND FALSE"), "{sql}");
2720        assert_eq!(binds, vec![Bound::Text("Hello".to_owned())]);
2721        assert_eq!(param, 5);
2722    }
2723
2724    #[test]
2725    fn the_reserved_tenant_key_is_still_honoured() {
2726        let mut param = 2;
2727        let mut binds = Vec::new();
2728        let sql = filter_sql(
2729            &definition(),
2730            &SearchFilter::default().equals(crate::backend::TENANT_FILTER_KEY, "acme"),
2731            &mut param,
2732            &mut binds,
2733        );
2734        assert_eq!(sql, " AND tenant_id = $2");
2735        assert_eq!(binds, vec![Bound::Text("acme".to_owned())]);
2736    }
2737
2738    #[test]
2739    fn the_fields_projection_casts_the_whole_merge_not_just_the_last_chunk() {
2740        // `::` binds tighter than `||`, so an unparenthesized
2741        // `a || b::text` would cast only `b` and stop being a jsonb merge.
2742        const PAIRS_PER_CHUNK: usize = 40;
2743
2744        // Under the chunk boundary: one call, still parenthesized.
2745        let single = fields_projection(&definition());
2746        assert!(single.starts_with("(jsonb_build_object("), "{single}");
2747        assert!(single.ends_with(")::text"), "{single}");
2748        assert_eq!(single.matches("jsonb_build_object(").count(), 1);
2749
2750        // Past it: several calls merged with `||`, and the cast must apply to
2751        // the whole expression.
2752        let fields: Vec<SearchIndexField> = (0..=(PAIRS_PER_CHUNK * 2))
2753            .map(|i| SearchIndexField::new(format!("field_{i}").leak(), 'D'))
2754            .collect();
2755        let mut wide = definition();
2756        wide.fields = std::borrow::Cow::Owned(fields);
2757        wide.embed_field = None;
2758
2759        let projection = fields_projection(&wide);
2760        assert_eq!(
2761            projection.matches("jsonb_build_object(").count(),
2762            3,
2763            "81 pairs at 40/chunk is 3 calls: {projection}"
2764        );
2765        assert!(projection.contains(" || "), "{projection}");
2766        assert!(projection.starts_with('('), "{projection}");
2767        assert!(projection.ends_with(")::text"), "{projection}");
2768        // The cast must not sit directly on a chunk.
2769        assert!(
2770            !projection.contains(")::text ||"),
2771            "the cast must wrap the merge, not a chunk: {projection}"
2772        );
2773    }
2774
2775    #[test]
2776    fn an_empty_field_list_still_yields_valid_json() {
2777        let mut empty = definition();
2778        empty.fields = std::borrow::Cow::Borrowed(&[]);
2779        assert_eq!(fields_projection(&empty), "'{}'::jsonb::text");
2780    }
2781
2782    #[test]
2783    fn an_invalid_definition_is_rejected_before_any_sql_is_built() {
2784        let mut bad = definition();
2785        bad.name = "articles\"; DROP TABLE users; --";
2786        assert!(matches!(checked(&bad), Err(SearchError::InvalidIndex(_))));
2787        assert!(checked(&definition()).is_ok());
2788    }
2789
2790    #[test]
2791    fn the_schema_is_idempotent_by_construction() {
2792        for statement in SCHEMA_STATEMENTS {
2793            let statement = statement.trim_start();
2794            assert!(
2795                statement.contains("IF NOT EXISTS") || statement.starts_with("CREATE OR REPLACE"),
2796                "not idempotent: {statement}"
2797            );
2798        }
2799    }
2800
2801    #[test]
2802    fn a_store_without_a_pool_reports_the_missing_configuration() {
2803        let store = PostgresSearchStore::new(None);
2804        let Err(error) = store.pool() else {
2805            panic!("a store with no installed pool must not report one");
2806        };
2807        // On SQLite, the backend refusal comes first (#2539 §5).
2808        let expected = autumn_web::backend_select! {
2809            pg => { "database.primary_url" },
2810            sqlite => { "SQLite" },
2811        };
2812        assert!(error.to_string().contains(expected), "{error}");
2813        assert!(store.vector_mode().is_none());
2814    }
2815
2816    #[test]
2817    fn vector_mode_reports_whether_pgvector_is_in_use() {
2818        assert!(VectorMode::PgVector { dimensions: 4 }.is_pgvector());
2819        assert!(!VectorMode::Array.is_pgvector());
2820    }
2821}