Repository navigation
Add the search index on PostgreSQL and SQLite and the search tool - #2566
Merged
Merged
Conversation
…ffold the migrations on v2
# Conflicts: # tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs # tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs
4 tasks done
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
This was referenced Oct 19, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
FluentCMS.Core.Search):[ExtensionPoint] ISearchIndex:IndexAsync(SearchDocument)inside the write's unit of work, andMatchAsync(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 andvia.SearchDocumentstable with a storedtsvectorunder thesimpleconfiguration and a GIN index.unicode61that keeps diacritics.EntryDirectoryindexes on create and update, inside the write's unit of work.EntrySearchjoins 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.in_reviewis read from the change sets holding the entry, asEntryDirectoryreads it, and the states filter uses the same state.IAgentPermissions.ReadableTypesAsync) are searched, and only readable matches lead to referencing entries. Atypesfilter naming a type that does not exist returnscontent_type_not_found, and one outside the scopeagent_scope_denied, aslist_entriesdoes, so neither is mistaken for no matches.search_entries(query, types, states, published_only, limit), read-only, with a row inMcpCoverageTests. The result has anextline naming the call that reads a hit, or what to change when nothing matched. An emptytypesorstateslist is refused withsearch_filter_empty.AddFluentCmsExtensiontakes a lifetime, so a database-backed extension can be scoped.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
Productentity and the page that references it.Choices worth a look:
complete: false, and the tool'snextsays so.Search__PageSizeandSearch__MaxMatchesset both. Only the columns a hit needs are read.tsvector, so a very long entry is still stored.Clocale, the migration's backfill splits non-ASCII letters, so entries that existed before it need a save to be found by such words (Index existing entries for search without depending on the database locale #2589). New writes are not affected.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:
complete: false. An unfiltered search stops after its first page.Down.in_review, a published entity inpublished, and the states filter keeps each by that state.ISearchreturns for it. States narrow the result, and an empty query, an unknown state and an empty filter are tool errors.typesfilter with an unknown type and one outside the scope returns both errors.The full suite ran locally against a
postgres:17container as well as SQLite, and markdownlint passes. No new package and no new project reference.Checklist
v2