Skip to content

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.

With TaskId extends Id<Task>, load one task by identity:

Task task = Fluxzero.loadModel(new TaskId("t1")).get();

This needs no search index. See loading Models for absent state and historical reads.

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

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.

@Model(searchable = true)
record Project(@EntityId ProjectId projectId, String status) {}
@Model
record 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.

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

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.

  1. Persistence: EVENT_SOURCED reconstructs Model state from events. DOCUMENT maintains internal current state. With both, events remain authoritative. An internal document does not by itself enable search.
  2. Searchability: @Model(searchable = true) maintains the canonical indexed node document. By default it also activates composed descendants. They use their own SearchSettings, and can be queried directly. A child’s local searchable = false means no independent activation; it does not veto inclusion by an ancestor.
  3. Graph materialization: graphProjection = @GraphProjection(mode = ...) is independent from search activation and controls only whether the complete composed Graph is stored. NONE is the default: no complete Graph document is materialized; indexed node documents are retained and Graph results are composed live when read. NONE does not disable Graph search. ASYNC materializes the Graph asynchronously; AWAIT also waits for affected projections before completing a commit. Application/consumer completion settings may additionally wait for an enabled projection, but never enable materialization in NONE.

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.

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.

ConfigurationAuthoritative loadNode documentNode/relationship searchGraph search
Event sourced, not searchableEventsNoneUnavailableUnavailable
DOCUMENT, not searchableInternal documentInternal, without search indexesUnavailableUnavailable
Event sourced, searchableEventsCanonical indexed nodeAvailableLive composition by default
DOCUMENT, searchableCanonical documentSame canonical indexed nodeAvailableLive composition by default
Searchable with ASYNC/AWAITUnchangedSame canonical indexed nodeAvailableStored 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.

@Model(searchable = true,
graphProjection = @GraphProjection(mode = GraphProjectionMode.ASYNC))
record Project(@EntityId ProjectId projectId, String status) {}

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.

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

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.

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.

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.

  1. 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.
  2. Keep canonical collection names stable. A SearchSettings.collection or logical-name change requires a separate controlled state migration; changing an annotation alone does not copy or adopt existing data.
  3. 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.
  4. 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.
  5. 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 until graphProjectionStatus(Root.class).isRebuilding() is false. Merely deploying a new annotation or running a live query does not retire an existing stored projector.
  6. 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 searchable to 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