Skip to content

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.

Here is a complete independent read-model example: store an article, then find it by title.

@Searchable
record 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);

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 declared collection, timestampPath, or endPath will 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");

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) {
}

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")
)

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.


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);

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();

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();

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.

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);

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);

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.


Fluxzero supports a rich set of constraints:

  • lookAhead("cat", paths...) – search-as-you-type lookups
  • query("*text & (cat* | hat)", paths...) – full-text search
  • match(value, path) – field match
  • matchFacet(name, value) – match field with @Facet
  • between(min, max, path) – numeric or time ranges
  • since(...), before(...), inLast(...) – temporal filters
  • anyExist(...) – match if any of the fields are present
  • Logical operations: not(...), all(...), any(...)
Fluxzero.search("payments")
.match("FAILED", "status")
.inLast(Duration.ofDays(1))
.fetchAll();

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...) and groupBy(paths...).aggregateAsync(fields...) return search statistics asynchronously.
  • facetStatsAsync() returns facet value counts asynchronously.
@HandleQuery
CompletableFuture<List<UserAccount>> handle(SearchUsers query) {
return Fluxzero.search(UserAccount.class)
.lookAhead(query.term(), "profile.name", "email")
.fetchAsync(50);
}

If you’re filtering on a field that is marked with @Facet, it’s better to use:

.matchFacet("status", "archived")

instead of:

.match("archived", "status")

While both achieve the same result, matchFacet(...) is generally faster and more efficient.


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”
@Searchable
public record Product(@Facet String category,
@Facet String brand,
String name,
BigDecimal price) {
}
List<FacetStats> stats = Fluxzero.search(Product.class)
.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.



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:

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) {
}

You can also exclude entire types:

@SearchExclude
public record EncryptedData(byte[] value) {
}

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.

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.

@SearchExclude
public record BaseDocument(String internalNotes,
@SearchInclude String publicSummary) {
}

Here, internalNotes will not be indexed, but publicSummary will be.


AnnotationEffect
@SearchExcludePrevents property/type from being indexed for search
@SearchExclude(false) or @SearchIncludeExplicitly includes a property even if a parent type is excluded
No annotationField 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.

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:

OperationCollection 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.

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.

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) {
}

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.

List<Product> results = Fluxzero.search(Product.class)
.between(10, 100, "price")
.sortBy("releaseDate")
.fetch(100);

Fluxzero normalizes and encodes sortable fields depending on their value type:

TypeBehavior
NumbersPadded base-10 string (preserves order, supports negatives)
InstantsISO-8601 timestamp format
Strings/OthersNormalized (lowercased, trimmed, diacritics removed)

This ensures that sorting is consistent and correct across types and locales.

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/propertyName path
  • A nested object annotated with @Sortable → Its toString() is used
  • A POJO with @Sortable fields → 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.

  • No retroactive indexing: Adding @Sortable to a field does not automatically reindex existing documents.
  • To apply sorting retroactively, trigger a reindex (e.g. with @HandleDocument and a bumped @Revision).
  • Sorting and filtering still happen within the Fluxzero Runtime, but without @Sortable the 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.

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

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);

This will return:

{
"id": "user123",
"profile": {
"name": "Alice",
"email": "alice@example.com"
},
"roles": [
"user",
"admin"
]
}

Fluxzero supports efficient streaming of large result sets:

Fluxzero.search("auditTrail")
.inLast(Duration.ofDays(7))
.stream().forEach(auditEvent -> process(auditEvent));

To remove documents from the index:

Fluxzero.search("expiredTokens")
.before(Instant.now())
.delete();

  • Use Fluxzero.index(...) to manually index documents.
  • Use @Searchable to configure the collection name or time range for an object.
  • Use @Model(persistence = {ModelPersistence.EVENT_SOURCED, ModelPersistence.DOCUMENT}) or @Stateful for automatic indexing.
  • Use Fluxzero.search(...) to query, stream, sort, and aggregate your documents.
  • Use fetchAsync(...), countAsync(), aggregateAsync(...), and facetStatsAsync() in asynchronous handlers.

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