Document indexing and search
Use the document store to find objects by their contents: open tasks, recent payments or a dashboard’s read model. Index ordinary values directly, or let searchable Models maintain their own indexed state.
Manual indexing
Section titled “Manual indexing”Here is a complete independent read-model example: store an article, then find it by title.
@Searchablerecord Article(@EntityId String articleId, String title) {}
Fluxzero.index(new Article("a1", "Getting started")).join();List<Article> articles = Fluxzero.search(Article.class).match("Getting started", "title").fetch(10);@Searchabledata class Article(@EntityId val articleId: String, val title: String)
Fluxzero.index(Article("a1", "Getting started")).join()val articles = Fluxzero.search(Article::class.java).match("Getting started", "title").fetch(10)The ID lets later indexing replace the same document. join() waits for this indexing operation before the query.
For ordinary domain changes, use Model commands; direct indexing is useful for an independent read model.
- If the object is annotated with
@Searchable, any declaredcollection,timestampPath, orendPathwill be used. - If a field is annotated with
@EntityId, it becomes the document ID. Otherwise, a random ID is generated. - Timestamps can be inferred from annotated paths or passed explicitly.
You can also specify the collection in which the object should be stored directly:
Fluxzero.index(myObject, "customCollection");Fluxzero.index(myObject, "customCollection")Model documents and searchable values
Section titled “Model documents and searchable values”Start ordinary domain state at @Model. The examples below enable searchable for a list of user accounts across the application. Node, relationship and Graph queries use the same canonical indexed documents. Neither DOCUMENT nor a composition path activates search. See the central
Model and Graph query matrix for required configuration, available predicates and actual state.
Other values use the document-store annotations that own automatic indexing:
@Model(searchable = true)@Stateful(implicitly@Searchable)- Directly annotate any POJO with
@Searchable
@Model(searchable = true)public record UserAccount(@EntityId UserId userId, UserProfile profile, boolean accountClosed) {}@Model(searchable = true)data class UserAccount( @EntityId val userId: UserId, val profile: UserProfile, val accountClosed: Boolean)For Models, the default canonical collection is $modelGraphComponents/<logical Model name>; @Model.name and fluxzero.model.namePrefix determine that logical name. Other document types default to their simple class name. Configure a Model’s collection and timestamp paths through searchSettings; @Stateful and directly searchable values expose the @Searchable settings themselves:
@Model( searchable = true, searchSettings = @SearchSettings(collection = "users", timestampPath = "profile/createdAt"))@Model( searchable = true, searchSettings = SearchSettings(collection = "users", timestampPath = "profile/createdAt"))Searchability is independent of persistence: DOCUMENT alone does not make a Model searchable. An indexed ancestor
can also include descendants in its searchable scope. See Choosing Model and Graph queries
when deciding which state to expose to search.
Querying indexed documents
Section titled “Querying indexed documents”Use the fluent search(...) API:
List<UserAccount> admins = Fluxzero .search(UserAccount.class) .match("admin", "profile/role") .inLast(Duration.ofDays(30)) .sortBy("profile/lastLogin", true) .fetch(100);val admins: List<UserAccount> = Fluxzero .search(UserAccount::class.java) .match("admin", "profile/role") .inLast(Duration.ofDays(30)) .sortBy("profile/lastLogin", true) .fetch(100)Consistency after commands
Section titled “Consistency after commands”Search is optimized for current state, but indexing still follows the commit path. A Model whose persistence set
contains DOCUMENT synchronously maintains its current document with the Model commit. Calling
sendCommandAndWait(...) and then querying that Model’s direct collection observes the committed state.
This does not automatically wait for a materialized whole-root graph projection. Select
GraphProjectionCompletion.AWAIT for the operation or consumer when an immediate root query must include all affected
children. Application-level configureGraphProjectionCompletion(AWAIT) is inherited by roots whose annotation keeps
DEFAULT. LocalClient, the test server and synchronous fixtures materialize the same projection before releasing
the result. Documents indexed by unrelated event handlers remain eventually consistent.
You can also query by class:
List<UserAccount> users = Fluxzero .search(UserAccount.class) .match("Netherlands", "profile.country") .fetchAll();val users: List<UserAccount> = Fluxzero .search(UserAccount::class.java) .match("Netherlands", "profile.country") .fetchAll()Searching multiple collections
Section titled “Searching multiple collections”The first class identifies a collection; it does not establish the result type of a combined search.
Each document is still deserialized to its stored type. In Java, an unqualified multi-collection chain uses Object;
in Kotlin, supply a type argument or assign the search to a typed Search variable.
List<Object> documents = Fluxzero.search(Project.class, Task.class).fetchAll();List<Project> projects = Fluxzero.<Project>search(Project.class, "archived-projects").fetchAll();val documents = Fluxzero.search<Any>(Project::class.java, Task::class.java).fetchAll()val projects = Fluxzero.search<Project>(Project::class.java, "archived-projects").fetchAll()Only choose a common result type when every matching document supports it. Single-class searches and Graph searches
retain their inferred types. Explicit terminal classes such as fetchAll(JsonNode.class) remain available for
alternative representations.
Selecting Models by parent or ancestor
Section titled “Selecting Models by parent or ancestor”When the related identity is already known, pass its typed ID directly. Fluxzero starts from the durable relationship index, so the parent or ancestor does not need a searchable or private current-state document:
List<Task> projectTasks = Fluxzero.search(Task.class) .whereParent(projectId) .fetch(100);
List<Task> organisationTasks = Fluxzero.search(Task.class) .whereAncestor(organisationId) .fetch(100);val projectTasks = Fluxzero.search(Task::class.java) .whereParent(projectId) .fetch(100)
val organisationTasks = Fluxzero.search(Task::class.java) .whereAncestor(organisationId) .fetch(100)Use whereAncestor(organisationId, 2, 2) to require exactly a grandparent. Typed Id<T> values supply their Model
type. For another functional-ID type, use whereAncestor(id, Organisation.class) in Java or
whereAncestor(id, Organisation::class.java) in Kotlin. When the related Model has a parent-scoped primary identity,
pass its already loaded Graph so Fluxzero can use the exact persisted identity.
When the related identity must instead be discovered from its current content, pass the Model type and constraints:
List<Task> activeProjectTasks = Fluxzero.search(Task.class) .whereParent(Project.class, MatchConstraint.match("ACTIVE", "status")) .fetch(100);val activeProjectTasks: List<Task> = Fluxzero.search(Task::class.java) .whereParent(Project::class.java, MatchConstraint.match("ACTIVE", "status")) .fetch(100)Use the class-and-constraint overload to select related Models by their own indexed content. Both the returned
Model and the related Model must be effectively searchable. They use their canonical node documents; a composed Graph
is not used as a node source. Ancestor-ID filters only require the returned Model to be searchable: the ancestor need
not have a document. DOCUMENT and pathInParent do not activate search. Load a non-searchable Model by ID instead.
Common filtering constraints
Section titled “Common filtering constraints”Fluxzero supports a rich set of constraints:
lookAhead("cat", paths...)– search-as-you-type lookupsquery("*text & (cat* | hat)", paths...)– full-text searchmatch(value, path)– field matchmatchFacet(name, value)– match field with@Facetbetween(min, max, path)– numeric or time rangessince(...),before(...),inLast(...)– temporal filtersanyExist(...)– match if any of the fields are present- Logical operations:
not(...),all(...),any(...)
Fluxzero.search("payments") .match("FAILED", "status") .inLast(Duration.ofDays(1)) .fetchAll();Fluxzero.search("payments") .match("FAILED", "status") .inLast(Duration.ofDays(1)) .fetchAll()Async search operations
Section titled “Async search operations”Search execution also has asynchronous variants for request handlers and web endpoints that can return a
CompletableFuture. Return the future directly instead of calling .join() inside the handler; this lets Fluxzero keep
processing other work while the search request is in flight.
fetchAsync(maxSize)fetches the search’s configured result type asynchronously. Pass an explicit result class only at a raw collection, JSON or document boundary.countAsync()returns the matching document count asynchronously.aggregateAsync(fields...)andgroupBy(paths...).aggregateAsync(fields...)return search statistics asynchronously.facetStatsAsync()returns facet value counts asynchronously.
@HandleQueryCompletableFuture<List<UserAccount>> handle(SearchUsers query) { return Fluxzero.search(UserAccount.class) .lookAhead(query.term(), "profile.name", "email") .fetchAsync(50);}@HandleQueryfun handle(query: SearchUsers): CompletableFuture<List<UserAccount>> = Fluxzero.search(UserAccount::class.java) .lookAhead(query.term, "profile.name", "email") .fetchAsync(50)Matching facet fields
Section titled “Matching facet fields”If you’re filtering on a field that is marked with @Facet, it’s better to use:
.matchFacet("status", "archived").matchFacet("status", "archived")instead of:
.match("archived", "status").match("archived", "status")While both achieve the same result, matchFacet(...) is generally faster and more efficient.
Facet statistics
Section titled “Facet statistics”When a field or getter is annotated with @Facet, you can also retrieve facet statistics — e.g., how many documents
exist per value of a given property.
Example: Product breakdown by category and brand
Section titled “Example: Product breakdown by category and brand”@Searchablepublic record Product(@Facet String category, @Facet String brand, String name, BigDecimal price) {}
List<FacetStats> stats = Fluxzero.search(Product.class) .lookAhead("wireless") .facetStats();@Searchabledata class Product( @Facet val category: String, @Facet val brand: String, val name: String, val price: BigDecimal)
val stats: List<FacetStats> = Fluxzero.search(Product::class.java) .lookAhead("wireless") .facetStats()This gives you document counts per facet value:
[ { "name": "category", "value": "headphones", "count": 55 }, { "name": "brand", "value": "Acme", "count": 45 }, { "name": "brand", "value": "NoName", "count": 10 }]Each FacetStats object will contain:
- the facet name (e.g.,
category) - the distinct values (e.g.,
"electronics","clothing") - the number of documents per value
Use facetStatsAsync() when the surrounding handler returns a CompletableFuture.
Search index exclusion
Section titled “Search index exclusion”By default, all non-transient properties of a document are included in the search index. However, you can fine-tune what fields get indexed using the following annotations:
@SearchExclude
Section titled “@SearchExclude”Use @SearchExclude to exclude a field or type from search indexing. This prevents the property from being matched in
search queries, though it will still be present in the stored document and accessible at runtime.
public record Order(String id, Customer customer, @SearchExclude byte[] encryptedPayload) {}data class Order( val id: String, val customer: Customer, @SearchExclude val encryptedPayload: ByteArray)You can also exclude entire types:
@SearchExcludepublic record EncryptedData(byte[] value) {}@SearchExcludedata class EncryptedData(val value: ByteArray)In this case, none of the properties of EncryptedData will be indexed, unless you override selectively with
@SearchInclude.
Exclusion is recorded per concrete field path: an excluded field remains excluded even when its value also occurs in a searchable field. Its value is still stored for deserialization; existence, range, facet and sorting operations retain their usual behavior. Documents indexed before per-path exclusions were recorded must be reindexed from typed values after upgrading both the writing SDK and Runtime. Upgrading readers alone cannot recover that missing metadata. Custom inverters and custom summarizers continue to define their own indexing semantics.
@SearchInclude
Section titled “@SearchInclude”Use @SearchInclude to override an exclusion. This is functionally equivalent to @SearchExclude(false) and is
typically used on a field or class that would otherwise be excluded by inheritance or parent-level settings.
@SearchExcludepublic record BaseDocument(String internalNotes, @SearchInclude String publicSummary) {}@SearchExcludedata class BaseDocument( val internalNotes: String, @SearchInclude val publicSummary: String)Here, internalNotes will not be indexed, but publicSummary will be.
Behavior summary
Section titled “Behavior summary”| Annotation | Effect |
|---|---|
@SearchExclude | Prevents property/type from being indexed for search |
@SearchExclude(false) or @SearchInclude | Explicitly includes a property even if a parent type is excluded |
| No annotation | Field is included in the search index by default |
skip(n) skips the first n results of the complete ordered search. It applies once, including when a synchronous or asynchronous fetch spans several transport batches. The transport fetch size does not change the selection.
Annotation-independent collection values
Section titled “Annotation-independent collection values”Enable fluxzero.search.collectionValues=true (FLUXZERO_SEARCH_COLLECTION_VALUES), or opt into
fluxzero.defaults.version=2026.10.09 or later. The dedicated property overrides the defaults version in either
direction. Configuration is resolved per application; an absent/older version retains compatibility behavior.
In this profile, adding or removing @Sortable does not change field range or sorting results:
| Operation | Collection behavior |
|---|---|
between(lower, upper, "prices") | The interval [min(prices), max(prices)] overlaps [lower, upper) |
sortBy("prices") | Minimum non-null price, ascending |
sortBy("prices", true) | Maximum non-null price, descending |
Explicit SearchValue.min(...) / max(...) | Compare/order the selected extremum |
Both [10, 100] and [40, 60] match [30, 70): gaps between values are deliberately ignored.
The comparison is max >= lower && min < upper. Empty or reversed query intervals never match.
Missing, empty and null-only fields do not match ranges and sort last in either direction. Negating such a range
includes those documents. Explicit Search.NullOrder.FIRST/LAST overrides null ordering. Sorting several fields
retains left-to-right precedence, including continuation pages and projected results that exclude a sort field.
Comparisons reuse the existing sortable encoding: numbers retain its six-decimal rounding and supported range;
text uses its lowercase, accent removal and trimming rules. These rules also apply without @Sortable in the new
profile. Empty strings remain values. Exact matching, facets, content exclusions and returned Graph content are unchanged.
import io.fluxzero.common.api.search.SearchValue;
// Same result with and without @Sortable on prices.var matching = Fluxzero.search("catalog").between(30, 70, "prices").fetchAll();var cheapest = Fluxzero.search("catalog").sortBy("prices").fetch(20);var highestBelow70 = Fluxzero.search("catalog") .constraint(SearchValue.max("prices").below(70)).fetchAll();var largestMinimum = Fluxzero.search("catalog").sortBy(SearchValue.min("prices"), true).fetch(20);val matching = Fluxzero.search<Any>("catalog").between(30, 70, "prices").fetchAll()val highestBelow70 = Fluxzero.search<Any>("catalog") .constraint(SearchValue.max("prices").below(70)).fetchAll()val largestMinimum = Fluxzero.search<Any>("catalog") .sortBy(SearchValue.min("prices"), true).fetch(20)Explicit SearchValue selection also works without changing the application’s defaults. Adding an explicit sort
selector opts the entire ordering into collection-value semantics: other ordinary field sorts in that search use
minimum-ascending/maximum-descending too, regardless of the order of the sortBy calls. Default document timestamp
ordering is unchanged. A root range on children/price compares the interval spanning all child prices; use whereChild(...) to require
multiple conditions to hold for the same child. Live composition and stored Graph projections share the field
comparison contract; live composition retains its existing Runtime composition/evaluation boundary.
Upgrade and reindex through your application
Section titled “Upgrade and reindex through your application”Upgrade every Runtime node before enabling this profile. New-profile requests use dedicated protocol envelopes; older receivers reject them instead of dropping unknown filters. The legacy maximum index remains readable.
The SDK retains the existing maximum and adds an optional minimum only when repeated values have different extrema. The Runtime stores this minimum as another string field in the existing sortable JSON and creates one additional minimum expression index for that path. Scalars and equal-valued arrays need no second value; scalar-only paths need no additional index. There is no separate values table, auxiliary JSON column or Runtime preparation/backfill API.
For an annotated collection/path without a minimum index, Runtime retains the original maximum SQL and behavior,
including explicit MIN. This also covers failed/incomplete minimum index creation. Once the index exists, it indexes
coalesce(minimum, maximum), including old rows without minimum JSON. Queries across collections preserve each
collection’s fallback in PostgreSQL.
Fallback is per document: an indexed row without a minimum uses its existing maximum for both endpoints, including
explicit SearchValue.min(...). For example, an old [10,100] row does not match [30,70); after reindexing it does.
Other already reindexed rows immediately use their own intervals. There is no whole-query readiness switch.
The customer reindexes from typed application state, for example through @HandleDocument, and owns scheduling,
progress and retries. Merely resending old persisted maximum-only entries does not reconstruct a minimum. Include the
Model source and Graph projection collections used by the query. Aliased @Sortable properties and custom sortable
objects use the same SDK-produced extrema; the Runtime does not decode raw document bodies to reconstruct them.
An old SDK write replaces the full sortable JSON, removing an earlier minimum and restoring that row’s maximum fallback.
Upgrade all Runtime readers before new SDK writes/reindexing: older Runtime readers expose the internal minimum strings
as ordinary sortable entries and do not implement the new query profile. Internal minimum keys use the reserved
$metadata namespace; applications must not define sortable aliases in that namespace.
Annotated filtering, sorting and pagination remain in PostgreSQL throughout partial reindexing. Unannotated paths
retain their existing Runtime evaluation route. Cursors retain the last returned ordering values, including projected
results; they are ordinary live keyset cursors, not snapshots. Concurrent updates/reindexing or minimum-index repair can move rows before or after
the cursor, as with any changing sort field. Finish reindexing before paging when a stable result set is required.
Low-level value queries must name their collections explicitly. Set the dedicated property to false to roll back query
semantics; this does not remove already stored minima or their indexes.
Compatibility behavior
Section titled “Compatibility behavior”Without opt-in, @Sortable collection ranges and both sort directions use the legacy maximum key. Unannotated paths
use the pre-existing document comparison route. Legacy rounding/normalization and null ordering remain unchanged.
The following encoding/index description documents that compatibility representation.
Rapid sorting and filtering
Section titled “Rapid sorting and filtering”To enable efficient range filters and sorted results in document searches, annotate properties with @Sortable:
public record Product(@Sortable BigDecimal price, @Sortable("releaseDate") Instant publishedAt) {}data class Product( @Sortable val price: BigDecimal, @Sortable("releaseDate") val publishedAt: Instant)This tells Fluxzero to pre-index these fields in a lexicographically sortable format. When you issue a search
with a between(...) constraint or .sortBy(...) clause, the Fluxzero Runtime can evaluate it directly in the data store —
without needing to load and compare documents in memory.
Optimized search example
Section titled “Optimized search example”List<Product> results = Fluxzero.search(Product.class) .between(10, 100, "price") .sortBy("releaseDate") .fetch(100);val results: List<Product> = Fluxzero.search(Product::class.java) .between(10, 100, "price") .sortBy("releaseDate") .fetch(100)What gets indexed
Section titled “What gets indexed”Fluxzero normalizes and encodes sortable fields depending on their value type:
| Type | Behavior |
|---|---|
| Numbers | Padded base-10 string (preserves order, supports negatives) |
| Instants | ISO-8601 timestamp format |
| Strings/Others | Normalized (lowercased, trimmed, diacritics removed) |
This ensures that sorting is consistent and correct across types and locales.
Nested and composite values
Section titled “Nested and composite values”If the sortable field is:
- A collection → Max element is indexed. Create a getter if you need sorting on the min element
- A map → Values are indexed using
key/propertyNamepath - A nested object annotated with
@Sortable→ ItstoString()is used - A POJO with
@Sortablefields → Those nested values are indexed with prefixed paths
For Graphs, a sortable path shared by multiple children also uses the maximum value. For example,
sortBy("children/price") orders roots by their highest child price in both ascending and descending order.
Range constraints on that sortable path use the same maximum; use whereChild(...) for a range that should match
an individual child. Exact matching still sees every child value. This applies to Java and Kotlin Models,
live Graphs, and materialized projections.
Important notes
Section titled “Important notes”- No retroactive indexing: Adding
@Sortableto a field does not automatically reindex existing documents. - To apply sorting retroactively, trigger a reindex (e.g. with
@HandleDocumentand a bumped@Revision). - Sorting and filtering still happen within the Fluxzero Runtime, but without
@Sortablethe logic falls back to in-memory evaluation — which is much slower. - Avoid re-implementing sorting/filtering in application code; express them in the search query so execution stays in the Runtime.
Customizing returned fields
Section titled “Customizing returned fields”When performing a search, you can control which fields are included or excluded in the returned documents.
This is useful for:
- Hiding sensitive fields (e.g. private data, tokens)
- Reducing payload size
- Optimizing performance when only partial data is needed
Example
Section titled “Example”Given the following indexed document:
{ "id": "user123", "profile": { "name": "Alice", "email": "alice@example.com", "ssn": "123456789" }, "roles": [ "user", "admin" ]}You can exclude sensitive fields like so:
Fluxzero.search("users") .exclude("profile.ssn") .fetch(50);Fluxzero.search("users") .exclude("profile.ssn") .fetch(50)This will return:
{ "id": "user123", "profile": { "name": "Alice", "email": "alice@example.com" }, "roles": [ "user", "admin" ]}Streaming results
Section titled “Streaming results”Fluxzero supports efficient streaming of large result sets:
Fluxzero.search("auditTrail") .inLast(Duration.ofDays(7)) .stream().forEach(auditEvent -> process(auditEvent));Fluxzero.search("auditTrail") .inLast(Duration.ofDays(7)) .stream() .forEach { auditEvent -> process(auditEvent) }Deleting documents
Section titled “Deleting documents”To remove documents from the index:
Fluxzero.search("expiredTokens") .before(Instant.now()) .delete();Fluxzero.search("expiredTokens") .before(Instant.now()) .delete()Summary
Section titled “Summary”- Use
Fluxzero.index(...)to manually index documents. - Use
@Searchableto configure the collection name or time range for an object. - Use
@Model(persistence = {ModelPersistence.EVENT_SOURCED, ModelPersistence.DOCUMENT})or@Statefulfor automatic indexing. - Use
Fluxzero.search(...)to query, stream, sort, and aggregate your documents. - Use
fetchAsync(...),countAsync(),aggregateAsync(...), andfacetStatsAsync()in asynchronous handlers.
Delivery and completion
Section titled “Delivery and completion”Indexing, indexIfNotExists, indexAndForget, deletion/movement, collection deletion, and audit-trail creation use
Guarantee.DEFAULT in SDK 2.x. The major-version default is STORED; override it with
fluxzero.publishing.defaultGuarantee (FLUXZERO_PUBLISHING_DEFAULT_GUARANTEE). The defaults date has no effect.
indexAndWait keeps explicit STORED. Ordinary bulk execute() keeps STORED, while executeAndForget() uses
DEFAULT. A future completes according to its selected guarantee; with NONE, joining it does not prove storage.
Commands started in an active tracking batch participate in its completion barrier even if their futures are ignored.
Outside tracking, retain the future to observe delivery failures. An explicit guarantee overrides application policy.
Instant comparison in the collection-value profile
Section titled “Instant comparison in the collection-value profile”Instant fields use the existing fixed-millisecond timestamp comparison both with and without @Sortable.
A whole second compares as .000Z; serialized nanoseconds remain in the document but comparison retains the
existing millisecond precision. This changes comparison keys only, not the document format or stored timestamp text.
Unannotated document entries retain no Java type information: valid uppercase UTC ISO text is interpreted as a
timestamp in this profile, including a literal String with that exact content. This guarantee concerns Instant
fields; annotated String fields keep their existing normalized text indexes. Lowercase, offset-form and invalid
ISO strings are ordinary text. Use consistently typed Instant fields for timestamp comparison.
Collection-value comparisons use normalized keys in Unicode code point order, matching PostgreSQL C, with and
without @Sortable. Runtime uses explicit C comparisons on databases with other collations; existing locale indexes
may not accelerate those comparisons. Legacy queries keep their existing database collation.
New writes choose string extrema in this order even when the collection-value query profile is disabled. Previously
stored maxima mixing supplementary characters with high BMP characters may therefore need application-owned reindexing
from typed state; a reader cannot recover an extremum discarded by an older writer. Upgrade all Runtime readers before
new SDK writes or reindexing, not merely before enabling the query profile.
© 2026 Fluxzero