Skip to content

Testing Model migrations

Moving Project.name into ProjectDetails.name must preserve the existing name. It is a schema refactor, not a business rename. Keep the Model’s logical name, identity and parent paths stable. Start with plain @Model; add DOCUMENT or a materialized Graph only when the queries under test require those representations.

Revision-1 com.example.CreateProject contains projectId and name. Revision 2 contains projectId and details:

{
"@class": "com.example.CreateProject",
"@revision": 1,
"projectId": "project-1",
"name": "Legacy name"
}

Save it as src/test/resources/project/create-project-rev1.json. The current message is @Revision(2) record CreateProject(String projectId, ProjectDetails details); its @Apply returns new Project(projectId, details). Project has @Model, @EntityId String projectId and ProjectDetails details; ProjectDetails is a plain value object containing String name.

class CreateProjectUpcaster {
@Upcast(type = "com.example.CreateProject", revision = 1)
JsonNode fromRevision1(ObjectNode payload) {
JsonNode name = payload.remove("name");
if (name == null || !name.isTextual()) {
throw new IllegalArgumentException("Revision 1 requires a textual name");
}
payload.putObject("details").set("name", name);
return payload;
}
}
@Test
void reconstructsTheOldCreation() {
TestFixture.create().registerCasters(new CreateProjectUpcaster())
.givenModelEvents("project-1", Project.class, "/project/create-project-rev1.json")
.whenExecuting(fc -> assertEquals(
new Project("project-1", new ProjectDetails("Legacy name")),
Fluxzero.loadModel("project-1", Project.class).get()))
.expectNoEvents().expectNoErrors();
}

Use io.fluxzero.common.serialization.Revision and io.fluxzero.sdk.common.serialization.casting.Upcast. The caster consumes revision 1, matching the resource, and yields 2. Do not default a present name to "Untitled". If absence is valid in the old schema, define and test that case deliberately; otherwise reject malformed data. Register a separate caster on each changed top-level event, snapshot or Model/document type. Annotating ProjectDetails alone cannot migrate every enclosing serialized type. An unchanged RenameProject(id, name) message can still wrap its name in details during current replay.

Spring discovers caster beans; a standalone fixture does not. TestFixture.create(caster) registers handlers, not casters. The first test must call .registerCasters(...), even if production uses @Component.

TestProvesDoes not prove
whenUpcasting(oldJson) after registerCastersOld type/revision converts to the expected current valueModel replay, old persisted bytes or indexes
givenModelEvents(id, oldEvent, ...), then load in WhenCurrent event decoding/upcasting and @Apply reconstructionByte-preserving old storage or another application’s restart
Old writer → retained test store → fresh candidate reader, no Given reseedingReads the actual artifacts written across that boundaryDatabase upgrade unless the database/service is also exercised

givenModelEvents accepts a typed Id<?>, or a raw ID plus Model class, followed by Java events, serialized messages or JSON resources. Register casters first. It applies the supplied events with current code and creates fresh commits, documents and relations. It skips original command assertions/interceptors, but its Given work can still publish events and notify observers. A new default fixture creates new local stores. Neither re-seeding it nor continuing with .andThen() proves a persistence restart.

The SDK checkout contains a runnable legacy-schema writer at docs/developer/examples/model-migration/LegacyModelWriter.java and the retained-store reader test at java-downstream-project/src/test/java/io/fluxzero/downstream/ModelMigrationDocumentationTest.java. Use the source from the same SDK checkout/tag as this guide. The writer creates and renames a Project and creates a Note through the public Fluxzero.assertAndApply(...) Model command path; it never assembles storage requests. The reader verifies replay, previous(), verified current state, parent navigation and searches, and then rematerializes the derived Graph. Only one generation of each logical Model type is on each application’s classpath.

Run the source writer with the old SDK dependency classpath, including its transitive dependencies, against an isolated test endpoint/namespace that remains alive between writer and reader:

Terminal window
java --class-path "$WRITER_CLASSPATH" LegacyModelWriter.java "$TEST_URL" "$TEST_NAMESPACE"

In a Maven old-writer project pinned to the old SDK, dependency:build-classpath -Dmdep.outputFile=writer-classpath.txt produces that classpath; do not combine old and new SDK/common JARs. Preserve the old application artifacts as the most realistic writer when testing an actual deployed schema. The example assumes a local test endpoint; use the normal client/property configuration for credentials elsewhere and never aim an example migration at production.

On the reader side, configure casters before building, create a new WebSocketClient to the same endpoint/namespace, and use TestFixture.createAsync(builder, client). Do not call any Given storage-seeding methods. Closing the old application closes its client, not the external test store; do not try to reconnect through that closed client. Close the reader before the server. In a real deployment use distinct processes and the actual persistent database when claiming a service/database upgrade, not just an in-memory test server.

Embedded TestServer creates document update logs lazily. The repository test opens the Graph collection’s tracking endpoint before starting the old writer, using getTrackingClient(DOCUMENT, collection).readFromIndex(0, 1) on a temporary client. This starts no migration and rewrites no data; it ensures the writer’s document updates are retained for the later consumer. Do not mistake an uninitialized in-memory update log for persisted database history.

The repository test normally uses the candidate SDK for both JVMs and an old-schema writer: that proves a schema migration, not old-SDK compatibility. Set -Dmigration.writer.classpath=/absolute/old-sdk-classpath when running that test to qualify a pinned old SDK too. The value is a platform-separated classpath string, not the name of the text file containing it. For example, from a prebuilt SDK checkout:

Terminal window
./mvnw -pl java-downstream-project -Dtest=ModelMigrationDocumentationTest \
-Dmigration.writer.classpath="$WRITER_CLASSPATH" test

The executable example explicitly enables DOCUMENT for direct Project searches and materialization for Project Graphs. This is an example of two concrete query needs, not a general Model default. The Note’s explicit notes parent path supplies its compositional document.

After the old writer stores name = "Renamed", and the new reader registers casters:

ReadBefore stored projection migrationAfter materialized Graph migration
loadModel(id).get().details().name()Renamed through replayUnchanged
loadModel(id).previous().get().details().name()Legacy nameUnchanged
loadCurrentModelState(id)Upcast maintained document at its verified headUnchanged
loadCurrentGraph(id).children(Note.class)Finds the old parent relationUnchanged
search(Project.class).match("Renamed", "name")Matches old stored path; returned object is upcastStill matches: direct document was not rewritten
search(Project.class).match("Renamed", "details/name")No matchStill no match
searchGraph(Project.class).match("Renamed", "details/name")No matchMatches migrated Graph projection

Content selection happens before deserialization. A caster cannot make the server search an absent new index path. The same applies to whereParent/whereAncestor content queries on component documents. Identity/relationship navigation is independent of moving name; live search composition still uses the stored component paths.

To migrate the materialized Graph, register the node casters and replay its document collection with this consumer:

@Consumer(name = "project-graph-schema-2", minIndex = 0)
class RematerializeProjects {
@HandleDocument(graphMigration = GraphMigrationTarget.PROJECTION)
Graph<Project> migrate(Graph<Project> graph) { return graph; }
}

If a node’s Java type name changes, register a type alias too, even when its Data upcaster returns the new type name. The Graph replacement guard needs a content-independent rename mapping. A root must upcast to exactly one state; split/drop upcasting is valid for ordinary documents, not for a Graph’s root state.

Observe catch-up before enabling new predicates. Returning the complete Graph rewrites only the derived projection when a node changed serialized type or revision, with a manifest guard against overwriting a newer projection. Bump the node’s revision for a JSON-shape migration; a JSON-only difference at the same type/revision does not trigger this rewrite. It does not migrate the source component documents or advance Model history. Rebuilding solely from old components can retain old paths; keep the migration consumer for the rollout window and verify behavior after subsequent writes too.

Reindex canonical nodes and optional stored Graphs

Section titled “Reindex canonical nodes and optional stored Graphs”

The projection-only walkthrough above opts into the earlier behavior. By default, returning the unchanged injected Graph migrates only evolved canonical nodes, including at NONE. The SDK upcasts each verified current source; it never copies stale Graph business values into it. Full-head/proof checks and bounded re-reads guard concurrent updates and deletion. In-place value changes and topology edits are rejected. Shared nodes are migrated once. Node storage completes before handling completes, while affected projections follow durably. AWAIT retains its ordinary Model-commit guarantee and is not a schema-migration barrier. Test current queries after catch-up, stale Graphs against newer state, retry after partial progress, and unchanged unrelated roots.

A searchable Model has one canonical indexed node document. DOCUMENT persistence can maintain that state without searchability. ASYNC/AWAIT additionally store the composed Graph. Schema rewrites do not advance Model history.

Handler selectionReads/writesQuery paths it updates
@HandleDocument with ProjectCanonical searchable nodeNode searches, live composition and related-content predicates
@HandleDocument(source = DocumentSource.MODEL_STATE) with ProjectMaintained internal state, including non-searchable DOCUMENT ModelsThe same canonical source; does not activate search
@HandleDocument with Graph<Project>Logical Graph updates in every modeReturn migrates evolved canonical nodes, then affected stored Graphs or NONE markers follow durably
@HandleDocument(graphMigration = GraphMigrationTarget.PROJECTION) with Graph<Project>Handled projection onlyStored ASYNC/AWAIT composition; NONE returns are observational

After registering the value-preserving upcasters above and raising the Model schema revision, use explicit consumers:

@Consumer(name = "project-source-schema-2", minIndex = 0)
class ReindexProjectSources {
@HandleDocument(source = DocumentSource.MODEL_STATE)
Project migrate(Project project) { return project; }
}

The source handler receives the upcast value. Return it unchanged: identity, type and logical serialized state must remain equal, including for mutable objects. Returning null, splitting/dropping the upcast state or changing business data is rejected; use commands/@Apply for state changes or deletion. A higher schema revision is required for a rewrite. The built-in Jackson serializer captures an immutable logical snapshot; a custom DocumentSerializer must explicitly implement modelStateSnapshot to support this new route. Existing ordinary document handling is unaffected.

Before invoking the handler the SDK inspects the verified current source. A delayed message whose upcast state no longer matches that source is not written. The eventual compare-and-set checks the full durable head, old proof and actual stored body atomically with the source/index/proof replacement. A concurrent update, deletion, recreation, untrusted document overwrite or prior schema rewrite makes the old request a successful no-op. No new Model version or domain event is produced. This guards the materialized source boundary; it is not a cross-database atomic snapshot of all Models. A void handler observes only and performs no rewrite.

Ordinary read-model documents retain their revision-aware return/deletion semantics. A typed Model handler uses the schema-only guard, whether selected through SEARCH or MODEL_STATE. Use commands for business state changes; direct indexing cannot certify Model state. For an independently transformed view, define a separate read model.

Migrate internal sources before rebuilding Graph projections, observe consumer catch-up, and test old/new paths separately for nodes and stored Graphs. Reindex each affected child’s source too: rewriting only the root does not migrate child predicates. Verify a fresh reader can still load current state and historical previous() values afterward.

Storage/configuration changes are separate migrations: a schema upcaster does not retire renamed collections, change persistence/name/path settings. Use the explicit reindex operation below for missing sources; qualify storage changes separately from the application-schema evolution shown here.

During a staged rollout, retain the old predicates, deliberately query both paths, or wait for migration before switching paths. Search results are current/projection state, not an event-bound snapshot or transaction readset. Use injected Models/Graphs for invariant checks, as explained in the query guide.

Historical values and the remaining test matrix

Section titled “Historical values and the remaining test matrix”

After migration, cold-load the Model and assert previous() still yields the original business value. Keep EVENT_SOURCED and reconstructible history for historical values. DOCUMENT alone is not a historical archive, even when its event policy also stores/publishes some events. loadCurrentGraph asks for current state; the Graph injected for an old event remains event-bound. Search does not replace either contract.

Extend the executable recipe to snapshots, aliases, parent paths, document-only state, encryption/KV references, namespaces, mixed old/new writers and persistent-service upgrade boundaries actually used by your application. Without the source history/documents, replay cannot reconstruct them. Unknown types or missing historical state should fail visibly, not be silently skipped. The small example does not certify these unexercised cases.

Call graph.reindex() to rebuild the canonical search source of that Model node with the current document serializer, indexed fields, exclusions and summary. This can create a missing source from complete event-sourced history. It preserves the Model head, events, aliases, relationships and history. A historical or event-bound Graph selects the identity only: reconstruction uses the latest committed state, never that view’s old values or staged updates. Reindex each selected child separately; the call does not enumerate descendants or select Models for you. DOCUMENT-only state still requires its existing verified source. Missing/deleted Models are no-ops; erased Models cannot be resurrected. Untrusted source bytes and incomplete reconstruction fail instead of creating provenance.

The customer owns selection, replay and progress. A bounded event consumer can invoke this operation repeatedly:

// Replace this example with the fixed cutover time for this migration, in millis << 16.
@Consumer(name = "project-search-reindex", minIndex = 0,
maxIndexExclusive = 1791058000000L << 16, exclusiveAfterMaxIndex = false)
class ReindexProjects {
@HandleEvent
void reindex(Object event, Graph<Project> project) { project.reindex(); }
}
@Consumer(name = "project-search-reindex", minIndex = 0,
maxIndexExclusive = 117378777088000000L, exclusiveAfterMaxIndex = false)
class ReindexProjects {
@HandleEvent
fun reindex(event: Any, project: Graph<Project>) { project.reindex() }
}

Use one fixed, positive maxIndexExclusive time boundary for the run, after stopping old writers and draining pending old materializations, and before starting writers with the new search configuration. Keep consumer and storage clocks comparable. A later configuration change needs a new boundary. This is a coordinated cutover, not mixed-writer migration. Source storage records an atomic durable storage-time index, distinct from Model state, functional timestamps and document tracking indexes. A verified source stored on or after the cutoff skips before Model replay and any write; ordinary writes under the new configuration count too. Old sources without this evidence are refreshed. Future or nonpositive cutoffs fail. Without a bounded consumer, each call refreshes unconditionally. A restarted replay must retain its original cutoff; do not calculate a new cutoff for every event.

The operation retries bounded head/proof conflicts. An exhausted conflict or storage failure may be retried by the customer. Concurrent writes, deletion and erasure retain their normal fences. Completion confirms the source write and durable targeted invalidation. With AWAIT completion it also waits for affected stored Graph projections, including ancestor roots, so a query immediately after reindex() sees the refreshed projection. This wait also applies when the fixed cutoff skips the source write. The consumer completion override takes precedence over the Model projection mode, then the application default (fluxzero.model.graphProjectionCompletion, environment FLUXZERO_MODEL_GRAPH_PROJECTION_COMPLETION), just as for normal commits. Other derived work continues asynchronously. The wait holds no Model write lock; a stalled projector can still delay the caller. Upgrade the Runtime before using this barrier: an older Runtime rejects its distinct request type instead of silently weakening AWAIT. No new Model event or automatic migration job is produced. Event replay can select only retained published events; use another authoritative ID inventory for Models absent from that log.


© 2026 Fluxzero