Skip to content

Add the search index on PostgreSQL and SQLite and the search tool - #2566

Merged
pournasserian merged 9 commits into
v2from
2559-search
Oct 19, 2026
Merged

pournasserian merged 9 commits into
v2from
2559-search

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #2559

What

The first slice of F-SRC (#2430): the search interface from ADR-0010 with its two in-database implementations, indexing in the write's transaction, text search with type and state filters, and the agent's search tool (SRC-01, MCP-01).

  • Core (FluentCMS.Core.Search):
    • [ExtensionPoint] ISearchIndex: IndexAsync(SearchDocument) inside the write's unit of work, and MatchAsync(terms, skip, limit), which returns entry ids with a rank, best first in a stable order, so a caller pages through them.
    • SearchText.From(fields): every string and number of an entry's fields, a fact's value and source included, as lowercase words, cut at 100,000 characters. A string that is a GUID, such as a reference or media id, is left out.
    • SearchTerms.From(query) splits a query into the same words.
    • ISearch.SearchAsync(SearchQuery) takes text, optional content types, states, published-only and a limit (20 by default, 100 at most). It returns hits with the type, state, versions, rank and via.
  • Infrastructure:
    • PostgreSQL: a SearchDocuments table with a stored tsvector under the simple configuration and a GIN index.
    • SQLite: an FTS5 table with unicode61 that keeps diacritics.
    • Both tables are outside the EF model and created by their migration, which also indexes every entry that already exists.
    • EntryDirectory indexes on create and update, inside the write's unit of work.
    • EntrySearch joins the matches with the entry rows. Deleted entries drop out, restored ones come back and published-only works, all without touching the index. It then follows each match to the entries whose latest version references it.
    • A hit's state is the one the entry tools show: in_review is read from the change sets holding the entry, as EntryDirectory reads it, and the states filter uses the same state.
  • Scopes: only types the caller may read (IAgentPermissions.ReadableTypesAsync) are searched, and only readable matches lead to referencing entries. A types filter naming a type that does not exist returns content_type_not_found, and one outside the scope agent_scope_denied, as list_entries does, so neither is mistaken for no matches.
  • Tool: search_entries (query, types, states, published_only, limit), read-only, with a row in McpCoverageTests. The result has a next line naming the call that reads a hit, or what to change when nothing matched. An empty types or states list is refused with search_filter_empty.
  • F-EXT: AddFluentCmsExtension takes a lifetime, so a database-backed extension can be scoped.
  • CONTRIBUTING.md: how search indexes and what an external engine implements.

Why

SRC-01 asks for full-text and structured search for agents and the desk, and ADR-0010 puts it in the application database behind an extension point. The epic's S2 case is an agent searching for "4411" and finding the Product entity and the page that references it.

Choices worth a look:

  • Words, not raw text: both indexes get the same lowercase words. The two tokenizers would otherwise split "19.99" and "A-4411" differently, and the same query has to find the same entries on both providers.
  • Prefix match on every term: "441" finds "A-4411", and every term must match.
  • Entry rows decide everything but the text, so publish, delete and restore need no index change, and the index stays an interface an external engine can implement.
  • Referencing entries are found through the reference index. Only a page's latest version counts, and the page shows the match it came through.
  • Matches are read a page at a time, 1,000 by default, until enough pass the scope, deletions and filters, the index has no more, or 10,000 are read, every entry at M1's scale. Better-ranked matches of a type the agent may not read cannot crowd out its own. A search that stops at the ceiling returns complete: false, and the tool's next says so. Search__PageSize and Search__MaxMatches set both. Only the columns a hit needs are read.
  • Indexed text is cut at 100,000 characters, well inside PostgreSQL's 1 MB limit on a tsvector, so a very long entry is still stored.
  • Known limits:
  • Not in this PR: filters on field values and references (next slice), the desk search box and pickers (desk lane), and semantic ranking.

Tests

Unit: text flattening (strings and numbers, facts, arrays, diacritics kept, GUIDs left out, the length cap) and query splitting.

Integration, each on SQLite and PostgreSQL:

  • "4411" finds the product entity and the page via it. "441" finds the same, two words narrow to entries with both, a type filter keeps the page, and published-only is empty until the product is published.
  • An entry rolled back with its unit of work is not found. Once its write commits it is found by its words, after an update by its new words only, not while deleted, and again after a restore. An update that rolls back leaves the old words found and the new ones not.
  • With a page of 20 and a ceiling of 60, a page-only search finds a page ranked behind 45 notes. Behind 65 notes it returns no hits and complete: false. An unfiltered search stops after its first page.
  • A product's id is not found as a word, before and after the backfill.
  • Entries that existed before the index are found after migrating down past it and up again, which exercises the backfill SQL and Down.
  • An entry in a submitted set is a hit in in_review, a published entity in published, and the states filter keeps each by that state.
  • An empty query, too many words, an empty filter and an out-of-range limit are refused with their codes.
  • Over HTTP, an agent scoped to pages finds the page and not the product with the same words, the same hits ISearch returns for it. States narrow the result, and an empty query, an unknown state and an empty filter are tool errors.
  • An agent scoped to pages does not find a page through the product it references, while a person finds both.
  • A types filter with an unknown type and one outside the scope returns both errors.
  • With a limit of 1, an agent scoped to pages finds its page past 15 better-ranked products. With the old budget of ten matches per hit this test fails on both providers.

The full suite ran locally against a postgres:17 container as well as SQLite, and markdownlint passes. No new package and no new project reference.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 19, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:infra Persistence, host, CI and build labels Oct 19, 2026
# Conflicts:
#	tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs
#	tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs
@pournasserian
pournasserian merged commit 193a452 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2559-search branch October 19, 2026 07:06
pournasserian added a commit that referenced this pull request Oct 19, 2026
…#2613)

Closes #2605

## What

The first slice of F-FRM (#2426): generated create and edit forms for
content types, with a widget per built-in field type through `FormField`
(FRM-01). A save is a person's operation in a change set (FRM-02), which
goes through checks and review like an agent's.

- **Core** (`FluentCMS.Core.Content`):
`IEntryEdits.SaveAsync(EntryEdit)`.
- It writes the version, then opens a change set with the person's
rationale and sources and adds the operation, all in one unit of work,
as `ProposedEntryWrite` does for agents. A refused set or field rolls
the version back, so no version exists outside a set (AUD-N1).
- An update is based on the published version, and a draft never
published is a create of its next version.
- The set stays open, and the person submits it from the set view
(#2603).
- **Admin**:
- `/content` lists the content types, and `/content/{slug}` lists a
type's entries, newest first, with state and versions. Both lists rove.
- `/content/{slug}/new` and `/content/{slug}/{id}` render the type's
latest definition, one `FieldWidget` per field. Each widget is in a
`FormField` with the field's description, a required mark and its error:
    - text: input with `maxlength`
    - richText: textarea
    - number: number input
    - boolean: checkbox
    - dateTime: `datetime-local` in UTC, or a date input for `dateOnly`
    - enum: select of its values
    - reference: select of the target type's entries
    - media: select of the library's assets
    - list and json: JSON textarea
- `FieldValues` turns each posted text into the field's JSON value, and
a stored value back into what the widget shows. Each value is checked
with `FieldTypeRegistry.ValidateValue` before the save, and a required
field left empty is reported. Each problem shows next to its field, and
the typed values stay.
- The directory checks the document again on save, and its "Field 'x':
..." errors go to their fields.
- An edit posts the version it read. A form from an older version is
refused, and the entry is shown as it is now.
- A save lands on the new set with a note saying to submit it. The
header gets a Content link.
- Entity types are listed, with a note that their form, with fact
metadata, is the next slice.
- **Accessibility**: the axe check covers `/content`, a type's entries
and the new-entry form.

## Why

- **A Core service for the save.** The form needs the same
version-plus-operation unit of work an agent's write uses. P1 keeps that
logic out of the screen, and a later tool for people's edits can call
the same service.
- **One set per save, left open.** A person may want several edits
reviewed together, but joining an existing open set needs a picker and
the CHG-07 rules. That comes with edit-in-place. Leaving the set open
lets the person look at the diff before submitting.
- **Selects for references and media for now.** The epic's pickers need
search (#2566 just landed). A select of every candidate works at M1
scale until the next slice.
- **UTC for date and time.** The browser's date-time input has no
offset, and the stored value needs one. The form says the time is UTC
rather than guessing the person's zone.
- **Not in this PR**: entity forms (next slice), search-backed pickers,
rule hints per field (FRM-03, needs F-RUL #2419), and editing inside an
existing set (CHG-07).

## Tests

Unit and bUnit (`GeneratedFormTests`):
- Every built-in type turns its posted text into the right JSON value. A
bad number, time, date or JSON is a problem, and an empty field has no
value.
- Stored values show as their widgets take them: a UTC date-time from
any offset, booleans, numbers as stored, and indented JSON with
non-Latin text unescaped.
- Each type renders its widget with the field's name, label link,
required mark and error, and an enum marks the stored value.

Integration:
- `EntryEditsTests` on SQLite and PostgreSQL:
- A save opens an open set with the person as initiator, the rationale,
the sources and the create.
- An edit of a draft is a create of version 2 in a new set, and a stale
base version is refused.
- A blank rationale or an invalid field stores neither the version nor a
set.
- `EntryFormScreenTests` against the host:
- A person creates an entry with a field of every type and lands on the
open set. The set holds the create with the sources split by line, and
every value is stored as typed.
- The edit form shows the stored values, and its save opens a second set
with version 2.
- Bad values and missing fields are reported next to their fields, and
nothing is stored.
  - A stale form shows the entry as it is now.
- The content pages list types and entries, need sign-in, and answer 404
for an unknown type or entry.

`dotnet build -warnaserror` and the full suite with the browser tests
pass locally against a `postgres:17` container as well as SQLite. One
run on a loaded machine had seven PostgreSQL failures in untouched
classes and one in the keyboard browser test; all 65 tests of those
classes passed on a rerun. No new package and no new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2621

## What

The third slice of F-FRM (#2426): pickers for reference and media fields
(FRM-01), backed by search (`ISearch`, #2566, #2606) and the media
library (#2543).

- **`PickerField`** (`Components/Forms`) is an interactive island inside
the static generated form. `FieldWidget` renders it for reference and
media fields.
- Over the circuit it shows the current choice, a Clear button and a
search box. The choice is a hidden input named `Form.Values[field]`, so
the form posts and saves exactly as before.
- **Entries**: as the person types, it searches the reference's target
type (at most 10 direct hits). Each hit shows its first text value, or
an entity's first text fact, with its state and a short id.
- **Media**: it filters the library by file name or folder, newest
first, with a preview for images.
- The matches are a `listbox` of buttons that rove with the module from
#2585. ArrowDown moves from the search box into the list, the arrows
move through it, Enter chooses and Escape clears the matches.
- Before the circuit attaches, or when it is lost, the prerendered
island is the plain select from #2613, so a form posted then still
saves.
- **`admin.js`**: a `data-ui="rove-input"` box moves focus to the first
item of the list its `aria-controls` names on ArrowDown.

## Why

- **An island, not a page in interactive mode.** Only the search needs
the circuit. The form, its other fields and the save stay static, so the
save path from #2613 does not change, and a lost circuit costs only the
search.
- **A hidden input in the island.** The static form collects it with the
other fields, and no server state has to survive between the choice and
the save.
- **`FieldOption[]` rather than an interface.** An island's parameters
cross into the circuit as JSON. With `IReadOnlyList<FieldOption>`, the
browser refused the root components with "The list of component
operations is not valid". The parameter says why in a comment.
- **Not in this PR**: field filters in the picker (`SearchQuery.Fields`
exists for it once a field's settings name a filter), list-of-reference
widgets, and rule hints (FRM-03, needs F-RUL #2419).

## Tests

- bUnit (`PickerFieldTests`):
  - Before the circuit, a picker is the select with the stored choice.
- An entry picker searches its target type and names hits by their first
text fact. It wires the listbox to the search box and the rove module,
and choosing posts the id and shows the name. Clear empties the choice.
  - A search with no match says so, and Escape clears the matches.
- A media picker finds an asset by file name, case-insensitively, with
its preview, and posts its id.
- `GeneratedFormTests` renders the reference and media widgets before
the circuit, as selects.
- Browser (`FormPickerTests`), keyboard alone: type the title, Tab to
the picker, type part of a product's name, press ArrowDown and Enter,
then write the rationale and save. The stored entry references the
product.

`dotnet build -warnaserror`, the unit and bUnit suite, the browser tests
and the SQLite integration suite pass locally. The local PostgreSQL run
on a loaded machine had nine failures in untouched classes, the same
pattern as the last three PRs. CI runs the full PostgreSQL suite. No new
package and no new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:infra Persistence, host, CI and build type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Search: the search index extension point on PostgreSQL and SQLite FTS5, indexed in the write, with the search tool

1 participant