Choosing Model and Graph queries
Start with the question your screen or handler needs to answer:
- Which task has this ID? Load the Model directly.
- Which tasks are open? Search indexed Model state.
- Which projects have open tasks? Search their relationships, returning Models or complete Graphs.
Load a known Model
Section titled “Load a known Model”With TaskId extends Id<Task>, load one task by identity:
Task task = Fluxzero.loadModel(new TaskId("t1")).get();val task = Fluxzero.loadModel(TaskId("t1")).get()This needs no search index. See loading Models for absent state and historical reads.
Search by content
Section titled “Search by content”Enable search on the Model whose contents you want to find:
@Model(searchable = true)record Task(@EntityId TaskId taskId, String status) {}
List<Task> openTasks = Fluxzero.search(Task.class).match("open", "status").fetch(20);@Model(searchable = true)data class Task(@EntityId val taskId: TaskId, val status: String)
val openTasks = Fluxzero.search(Task::class.java).match("open", "status").fetch(20)Search reads indexed current state. It does not provide an event-time snapshot or the exact state read inside a Model
update. Start with a bounded page; use fetchAll() only when you intend to load all matches.
A searchable Project and its Tasks
Section titled “A searchable Project and its Tasks”@Model(searchable = true)record Project(@EntityId ProjectId projectId, String status) {}
@Modelrecord Task(@EntityId TaskId taskId, @Parent(pathInParent = "tasks") ProjectId projectId, String status) {}
List<Graph<Project>> projects = Fluxzero.searchGraph(Project.class) .whereChild(Task.class, MatchConstraint.match("open", "status")) .fetch(10);Now each Task is related to a Project. The searchable Project activates search for its composed Tasks, so the child
needs no separate searchable = true. pathInParent = "tasks" names their place in the returned Graph.
ProjectId and TaskId are typed IDs for these Models.
whereChild selects Projects that have at least one open Task. The returned Graph includes all participating
Tasks, not only the open ones. To return just matching Tasks, search Task.class instead.
A plain @Parent without pathInParent still supports navigation by identity, but does not embed the child in a
search Graph or pass search activation through that edge. All writers must share the relevant typed parent contract;
configuration changes do not backfill old data. See migration before changing stored Models.
IDs and related content
Section titled “IDs and related content”List<Task> projectTasks = Fluxzero.search(Task.class) .whereAncestor(projectId) .fetch(100);
List<Task> activeProjectTasks = Fluxzero.search(Task.class) .whereAncestor(Project.class, MatchConstraint.match("active", "status")) .fetchAll();val projectTasks: List<Task> = Fluxzero.search(Task::class.java) .whereAncestor(projectId) .fetch(100)
val activeProjectTasks: List<Task> = Fluxzero.search(Task::class.java) .whereAncestor(Project::class.java, MatchConstraint.match("active", "status")) .fetchAll()The ID overload needs only the searchable result nodes and durable relationships. The ancestor need not have a
search document. A predicate on ancestor content requires that ancestor to be searchable. A typed Id<T> supplies
the related type; for other identifiers pass the Model class as well. Use a loaded Graph for a parent-scoped identity.
Use whereParent, whereAncestor, whereChild and whereDescendant for explicit relationship predicates. Multiple
constraints in one whereChild must match the same child. Separate whereChild calls may match different children.
These predicates select result roots; they never prune the returned Graph’s children.
Three independent choices
Section titled “Three independent choices”- Persistence:
EVENT_SOURCEDreconstructs Model state from events.DOCUMENTmaintains internal current state. With both, events remain authoritative. An internal document does not by itself enable search. - Searchability:
@Model(searchable = true)maintains the canonical indexed node document. By default it also activates composed descendants. They use their ownSearchSettings, and can be queried directly. A child’s localsearchable = falsemeans no independent activation; it does not veto inclusion by an ancestor. - Graph materialization:
graphProjection = @GraphProjection(mode = ...)is independent from search activation and controls only whether the complete composed Graph is stored.NONEis the default: no complete Graph document is materialized; indexed node documents are retained and Graph results are composed live when read.NONEdoes not disable Graph search.ASYNCmaterializes the Graph asynchronously;AWAITalso waits for affected projections before completing a commit. Application/consumer completion settings may additionally wait for an enabled projection, but never enable materialization inNONE.
searchable defaults to false. Set it to true only when that Model should independently activate search.
The controls are deliberately orthogonal: Model starts the scope, SearchSettings.includeDescendants limits that
root’s scope, Parent.propagateSearch gates composed edges, and graphProjection.mode chooses live versus materialized
Graph search. A pathless parent is navigable but does not propagate search activation. SearchSettings alone never
activates search. Event publication, caching, snapshots and conflict handling remain separate choices.
Capability matrix
Section titled “Capability matrix”These rows assume data written by updated writers sharing the same Model contract. Search is current-state access; it is not an event-time snapshot or a transaction readset.
| Configuration | Authoritative load | Node document | Node/relationship search | Graph search |
|---|---|---|---|---|
| Event sourced, not searchable | Events | None | Unavailable | Unavailable |
| DOCUMENT, not searchable | Internal document | Internal, without search indexes | Unavailable | Unavailable |
| Event sourced, searchable | Events | Canonical indexed node | Available | Live composition by default |
| DOCUMENT, searchable | Canonical document | Same canonical indexed node | Available | Live composition by default |
| Searchable with ASYNC/AWAIT | Unchanged | Same canonical indexed node | Available | Stored composed Graph; explicit live override remains available |
A Model is searchable either explicitly or through the composed scope of a searchable ancestor. Node queries, relationship predicates and Graph composition use the same canonical content. There is no separate independently editable public Model projection. Use a separate ordinary read model for a customized representation.
Searchability is not an authorization boundary. Raw collection access remains a low-level operation; using it to rewrite Model state does not provide Model consistency guarantees.
Excluding a branch from ancestor search Graphs
Section titled “Excluding a branch from ancestor search Graphs”Use @Parent(pathInParent = "tasks", propagateSearch = false) to stop search composition at that edge.
The Task and its complete subtree are absent from ancestor search documents in NONE, ASYNC and AWAIT, and their
changes do not trigger those ancestor Graph-document subscriptions. The default is true.
This is independent of @Model(searchable = ...): a Task with its own searchable = true remains directly searchable
and can expose its own Graph, including its children. A false Model setting does not veto inherited activation;
a false Parent.propagateSearch setting blocks inheritance and composition through that relationship. Another included parent
edge can still expose the same Task. If two declarations resolve to the exact same parent/type/path, exclusion wins.
Ordinary Graph navigation, domain/event Graphs, response serialization and deletion ownership retain the relationship and its path. Explicit relationship predicates, including ancestor-ID filters, still work for independently searchable nodes; the setting controls the composed search body, not which relationships exist. It is not an authorization rule.
The edge policy is persisted. Old relationships without this flag retain inclusion. Changing the annotation, re-registering a projection or making a value-only update does not rewrite existing relationships: migrate their policy explicitly, then rebuild existing stored Graphs. Stop old writers during that migration. No automatic relationship-policy migration is provided.
Optional stored Graph
Section titled “Optional stored Graph”@Model(searchable = true, graphProjection = @GraphProjection(mode = GraphProjectionMode.ASYNC))record Project(@EntityId ProjectId projectId, String status) {}@Model(searchable = true, graphProjection = GraphProjection(mode = GraphProjectionMode.ASYNC))data class Project(@EntityId val projectId: ProjectId, val status: String)This additionally stores the composed Graph. The default collection is the logical Model name plus -graphs, or the
explicit node collection plus -graphs. A configured Graph collection must differ from the canonical node collection.
Fluxzero.searchGraph(Project.class) uses the stored Graph; Fluxzero.searchGraph(Project.class, true) forces live
composition. Materialization does not change Model loading, event-handler Graph injection, or descendant activation.
ASYNC may lag behind current node documents. AWAIT waits for affected projections; it does not give arbitrary
multi-query transactions or retain historical search snapshots. Multiple root projections can share canonical nodes,
but each composed projection stores its own Graph body.
Search results and costs
Section titled “Search results and costs”NONE, ASYNC and AWAIT use the same indexed field exclusions, composition paths and result shape. @SearchExclude
keeps a field readable in returned state while excluding it from text matching. Live composition preserves each
node’s search summary instead of reconstructing it from every deserializable field.
Both live and stored searches return complete composed documents for the selected Graphs. Large Graphs therefore cost memory, conversion work and response bytes. Materialization saves query-time composition; it does not make a large response small. Use a separate read model when the screen needs only a compact summary.
Typed Graph results require complete documents. For partial output, request JSON explicitly:
List<ObjectNode> rows = Fluxzero.searchGraph(Project.class) .match("open", "tasks/status") .includeOnly("tasks") .fetch(10, ObjectNode.class);val rows: List<ObjectNode> = Fluxzero.searchGraph(Project::class.java) .match("open", "tasks/status") .includeOnly("tasks") .fetch(10, ObjectNode::class.java)Node/relationship and Graph searches support synchronous/asynchronous retrieval, counts, grouped numeric statistics and facets. Statistics cover the entire filtered selection; result paging and field selection do not limit them. Live Graph and relationship statistics currently compute over fetched document representations. They can consume considerably more memory and bandwidth than an ordinary stored-document aggregate.
fetch(10) limits the returned roots, not necessarily the composition work needed to find them. For broad queries
over large Graphs, consider stored materialization and explicit traversal/output bounds. Relationship predicates can
narrow selection before composition. See Search for supported filters and bounds.
Migrating existing data
Section titled “Migrating existing data”DocumentProjection and materializeGraph have been removed. Set searchable = true on Models that should
independently activate search; omitting it keeps the default false. Move node collection and timestamp settings to
SearchSettings, and choose a GraphProjection mode separately. Kotlin nested annotations omit @, for example
searchSettings = SearchSettings(includeDescendants = false).
The default canonical collection retains the existing internal $modelGraphComponents/<logical Model name> source.
An old public DocumentProjection collection must not be blindly copied into SearchSettings: it is a projection,
not authoritative state. Retain the old canonical collection or perform a controlled data migration. Upgrading code
does not backfill absent child documents or their exact summaries. Coordinate writers and rebuild/reindex sources
before relying on the new search contract for old data.
Use normal Model commands for business changes. Source schema-upcasts must preserve logical state and durable head
proof. Returning an upcast Graph migrates evolved canonical nodes by default; use
GraphMigrationTarget.PROJECTION to update only its stored composition. See the Model migration guide for
mixed-writer and stored-data transition requirements.
Coordinating the upgrade
Section titled “Coordinating the upgrade”This is a coordinated 2.x contract change, not a rolling mixed-writer upgrade. Stop old Model writers, source/Graph
migration consumers and Graph subscribers. Upgrade all Runtime instances first, then rebuild every participating
contract/application against the current Model contract, setting searchable = true only where independent search
activation is intended. Old DocumentProjection, materializeGraph, HandleDocument.modelGraph and
HandleDocument.modelState declarations no longer compile. Rebuild shared contract JARs too so their Model index and
search declarations match the current SDK.
An old writer can re-register an aggregate definition after it was changed to NONE; do not run it alongside the new
configuration. Reading old stored projection definitions remains supported (missing storeGraph means the old
aggregate behavior); that is not permission to mix old and new writers.
- Inventory canonical source collections, logical Model names, Graph collections and active consumers. Preserve authoritative DOCUMENT-only state and its Model heads/proofs. A formerly public Model collection is not a source.
- Keep canonical collection names stable. A
SearchSettings.collectionor logical-name change requires a separate controlled state migration; changing an annotation alone does not copy or adopt existing data. - Backfill newly searchable nodes with
Graph.reindex()from authoritative current state before exposing queries. A document consumer cannot visit absent sources; select Model IDs or replay retained events yourself. Follow the fixed-cutoff reindex contract. The operation preserves history and validates the live head; ordinary index writes or fabricated events cannot substitute for it. Missing DOCUMENT-only state cannot be reconstructed without authoritative history. - Reindex existing canonical sources with their exact search summaries/exclusions, facets and sortables. An unchanged type/revision handler return is a no-op; use an explicit schema revision and a state-preserving upcast when using the guarded source migration route. A source rewrite never acts as a business-state update.
- Re-register every existing Graph definition against the new settings and rebuild after source catch-up. For
ASYNC/AWAIT to NONE, call
modelRepository().registerGraphProjection(Root.class, true).join()with the new Model declaration. This changes the durable definition and replaces aggregate bodies with small notification markers. Wait untilgraphProjectionStatus(Root.class).isRebuilding()is false. Merely deploying a new annotation or running a live query does not retire an existing stored projector. - Start the new consumers and qualify node, ancestor-ID/content and Graph queries, including moves/deletes, against
the migrated data before resuming normal writers. Remove obsolete public copies only after dependent read models
and subscriptions have moved. Retiring an entire Model/search definition is a separate administrative operation;
changing
searchableto false does not delete old data or unregister durable Runtime definitions.
A NONE Graph handler registers its durable notification definition; query-only NONE does not create that stream. Cancelling a handler stops consumption, not the persisted definition: later subscribers can catch up. Schema-only source rewrites request a durable rebuild of affected registered definitions. Runtime discovery also covers writes received by another process; an idle instance checks at five-second intervals. Such maintenance can notify other roots of the same definition and is not an event-by-event audit log.
All writers must share the relevant typed composition contract and effective search settings. An untyped parent ID cannot inherit search activation; enable the child explicitly. For polymorphic parents, the writer’s Model catalog must include the concrete parent types and their ancestors to guarantee their AWAIT settings. The SDK expands that catalog without loading ancestor values; Runtime selects the actual old/new roots from the commit’s relationship history. A writer that only knows an open base contract cannot infer unknown concrete applications’ completion policy.
© 2026 Fluxzero