Migrate a 0.x application to the v1 model
Version 1 establishes an identity-free public entity model and native provider storage. This is a deliberate pre-v1 compatibility break, not an in-place database upgrade.
Read this before changing data
Back up every database before attempting a manual transition.
CVOYA Graph does not ship or support:
- a reader for alpha-era universal IDs, endpoint IDs, universal roots, or legacy metadata;
- a mixed legacy/native read mode;
- dual writes;
- an automatic migration or backfill;
- automatic deletion, relabeling, or rewriting of an existing database; or
- a supported SQL/Cypher transformation script.
Update and validate the application model first. Then create a clean v1 graph and reimport from a trusted source, or design and review your own data transformation outside the library. Do not point the v1 provider at alpha data and assume it will reinterpret it.
1. Remove universal entity identity
IEntity is now an empty marker. Node and Relationship no longer generate or expose an Id.
Before:
public record Person : Node
{
// In 0.x this was inherited and treated as universal graph identity.
}
After, choose deliberately between keyless and domain-keyed models:
[Node(Label = "AuditEntry")]
public record AuditEntry : Node
{
public DateTime RecordedAt { get; init; }
public string Message { get; init; } = string.Empty;
}
[Node(Label = "Customer")]
public record Customer : Node
{
[Property(IsKey = true)]
public string Tenant { get; init; } = string.Empty;
[Property(IsKey = true)]
public string CustomerNumber { get; init; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
}
Multiple IsKey properties form one composite tuple. Keys are optional domain constraints, not
provider-native identity, implicit mutation targets, or endpoints. Key components are non-null
graph-storable scalars and imply required/indexed behavior.
If the domain genuinely has a property named Id, keep it:
public record ExternalRecord : Node
{
public string Id { get; init; } = string.Empty; // ordinary mapped data
}
Add IsKey = true only if that property is the domain key. Its name alone has no meaning.
2. Remove relationship-owned endpoints and direction
IRelationship now contains only runtime Type. The Relationship base record has no endpoint
IDs and no direction:
[Relationship(Label = "KNOWS")]
public record Knows : Relationship
{
public DateTime Since { get; init; }
}
Do not copy StartNodeId, EndNodeId, or provider element IDs into the new relationship type.
Endpoint intent belongs to the create command. Physical orientation is returned on
IGraphPathSegment.Direction, relative to the segment’s StartNode and EndNode.
A domain property named Direction remains legal ordinary data:
public record ShippingInstruction : Relationship
{
public string Direction { get; init; } = string.Empty;
}
It does not control edge orientation.
3. Replace create calls with endpoint intent
Create a standalone node with CreateNodeAsync:
await graph.CreateNodeAsync(new Person { Email = "alice@example.com" });
Create two new endpoints and the relationship atomically:
await graph.CreateAsync(
new Person { Email = "alice@example.com" },
new Knows { Since = DateTime.UtcNow },
new Person { Email = "bob@example.com" });
Connect existing endpoints with exactly-one selections:
var alice = graph.Nodes<Person>()
.Where(person => person.Email == "alice@example.com");
var bob = graph.Nodes<Person>()
.Where(person => person.Email == "bob@example.com");
await graph.CreateRelationshipAsync(alice, new Knows(), bob);
Both selections are frozen in the write transaction and must resolve to exactly one node. A zero or multi-row selection fails. This explicit contract replaces hidden ID lookup.
Hybrid and self-loop shapes are public:
// Existing source, new target.
await graph.CreateAsync(
alice,
new Knows(),
new Person { Email = "charlie@example.com" });
// New source, existing target.
await graph.CreateAsync(
new Person { Email = "dana@example.com" },
new Knows(),
bob);
// One new node used as both endpoints.
await graph.CreateSelfLoopAsync(
new Person { Email = "self@example.com" },
new Knows());
Pass RelationshipDirection.Incoming to a create overload only when the physical edge must be
stored opposite the source/target command intent. Most models use the default Outgoing.
4. Replace detached-entity mutation with set-based mutation
Whole-entity update/delete APIs have been removed. Select the target rows, then apply typed setters or delete the selection:
var selected = graph.Nodes<Person>()
.Where(person => person.Email == "alice@example.com");
var affected = await selected.UpdateAsync(setters => setters
.SetProperty(person => person.Age, person => person.Age + 1)
.SetProperty(person => person.Profile, new Profile { DisplayName = "Alice" }));
var deleted = await graph.Nodes<Person>()
.Where(person => person.Email.EndsWith("@expired.example"))
.DeleteAsync(cascadeDelete: true);
SetProperty covers scalar, constrained, collection, and complex properties. Key/unique/required
validation and complex-subtree replacement happen in the write transaction. Node deletion refuses
user-defined relationships unless cascadeDelete: true; relationship selections use their own
DeleteAsync() overload.
The selected set is frozen and de-duplicated before mutation, so a traversal that reaches one entity more than once still mutates it once.
5. Use synchronous query roots
Building a query performs no I/O. Query roots therefore no longer have Async suffixes and are not
awaited:
| 0.x | v1 |
|---|---|
await graph.NodesAsync<Person>() |
graph.Nodes<Person>() |
await graph.RelationshipsAsync<Knows>() |
graph.Relationships<Knows>() |
await graph.DynamicNodesAsync() |
graph.DynamicNodes() |
await graph.DynamicRelationshipsAsync() |
graph.DynamicRelationships() |
await graph.SearchNodesAsync<Person>(text) |
graph.SearchNodes<Person>(text) |
await graph.SearchRelationshipsAsync<Knows>(text) |
graph.SearchRelationships<Knows>(text) |
Execute I/O with an async terminal or await foreach:
var people = await graph.Nodes<Person>()
.Where(person => person.Active)
.ToListAsync(cancellationToken);
await foreach (var person in graph.Nodes<Person>()
.WithCancellation(cancellationToken))
{
// Incremental provider streaming.
}
IGraphQueryable<T> is the one queryable type for nodes, relationships, projections, and scalars.
The old per-kind queryable interfaces are gone.
6. Update async terminal expectations
The terminal names and empty-sequence behavior follow standard LINQ:
FirstAsync,LastAsync, andSingleAsyncthrowInvalidOperationExceptionon an empty sequence.FirstOrDefaultAsync,LastOrDefaultAsync, andSingleOrDefaultAsyncreturn default on empty.SingleAsyncandSingleOrDefaultAsyncthrow when more than one row matches.AverageAsyncreturns the standard LINQ numeric result type; nullable empty/all-null inputs return null, while non-nullable empty inputs throw.- Every async terminal accepts
CancellationToken.
Replace code that relied on FirstAsync or SingleAsync returning null with the corresponding
OrDefault form.
7. Update traversal calls
Depth and direction are options on the traversal itself; free-floating WithDepth/Direction
modifiers are gone:
var friends = await graph.Nodes<Person>()
.Where(person => person.Email == "alice@example.com")
.Traverse<Knows, Person>(options => options
.Depth(1, 3)
.Direction(GraphTraversalDirection.Both))
.ToListAsync();
Traverse<TRelationship, TEnd> and TraversePaths<TRelationship, TEnd> use two generic type
arguments. PathSegments<TStart, TRelationship, TEnd> retains all three because its result exposes
the typed start node.
Use path segments to inspect a relationship’s endpoints and orientation:
var segments = await graph.Nodes<Person>()
.Where(person => person.Email == "alice@example.com")
.PathSegments<Person, Knows, Person>(GraphTraversalDirection.Both)
.ToListAsync();
foreach (var segment in segments)
{
var physicalDirection = segment.Direction;
var sourceInThisResult = segment.StartNode;
var targetInThisResult = segment.EndNode;
}
There is no RelationshipDirection.Bidirectional stored shape. Query both directions with
GraphTraversalDirection.Both.
8. Remove hidden reference and whole-entity assumptions
There is no public GraphReference, hidden identity convention, or portable whole-entity ordering.
Project or compare domain scalar properties:
var ordered = await graph.Nodes<Person>()
.OrderBy(person => person.Email)
.ThenBy(person => person.Name)
.ToListAsync();
Neo4j declares GraphCapability.OrderByEntity; AGE and in-memory do not. Scalar ordering is the
portable contract.
Do not retain a provider element ID beyond the operation that produced it. Provider-native identity is private and may be database-, graph-, transaction-, or provider-specific.
9. Adopt native storage and external-data behavior
Neo4j and AGE v1 write normal mapped labels/types and ordinary user properties. They do not require universal CVOYA node/relationship roots. Compatible native rows created outside CVOYA Graph can be read as typed or dynamic entities and can participate in traversal and full-text queries.
Read-only queries do not provision labels, types, indexes, functions, or missing graphs. Write paths provision only what the operation needs. Managed-index recreation is ownership-bounded: Neo4j touches only positively identified provider-owned indexes, while AGE and in-memory complete as successful no-ops.
This is why alpha databases must be recreated/reimported rather than opened in place.
10. Preserve nullable collection semantics
V1 preserves nullable elements in simple and complex collections:
public record ImportBatch : Node
{
public List<string?> Codes { get; set; } = [];
public List<Address?> Stops { get; set; } = [];
}
Create, read, replacement, typed/dynamic materialization, and supported predicates preserve exact count, order, null positions, and declared element type, including empty and all-null collections. A null targeting a non-nullable element fails. Provider physical encodings are private.
Do not retain a 0.x workaround that removed nulls, inserted sentinels, or exposed companion properties.
11. Account for provider capabilities
The portable contract is explicit:
| Surface | Neo4j | AGE | In-memory |
|---|---|---|---|
| Full-text search | Yes | Yes | Yes, index-free contract floor |
| Relationship predicates | Yes | Yes | Yes |
Union / Concat |
Yes | Yes | Yes |
| Nullable simple collection elements | Yes | Yes | Yes |
| Shortest path | Yes | No in v1.0 | Yes |
| Whole-entity ordering | Yes | No | No |
AGE shortest-path queries fail translation, and its capability-gated compatibility test skips with
the standard missing-capability reason. AGE RelationshipPredicates and SetOperations are
supported; do not carry forward old workarounds or documentation that says otherwise.
Suggested transition order
- Back up the alpha database and preserve the original application/export toolchain.
- Update entity base types and remove inherited universal-ID assumptions.
- Decide which entities are keyless and which need explicit single/composite domain keys.
- Remove relationship endpoint/direction members that existed only for persistence.
- Replace create, update, and delete calls with endpoint-intent and set-based APIs.
- Update synchronous roots, traversal calls, and async terminal expectations.
- Remove legacy null-collection workarounds and provider identity/reference assumptions.
- Build the application and examples against v1.
- Create an empty v1 graph and reimport from a reviewed source.
- Validate counts, domain-key uniqueness, relationships, orientation, complex properties, nullable collections, and provider capabilities before cutover.
The library will not mutate the old database during these steps.