Schema and migrations¶
GraphBridge never changes your database schema on its own. You compare the schema your EF model needs with what the database has, review the proposed constraints and indexes, and apply changes deliberately: by hand, in a deployment gate, or with a typed migration plan that has an offline preview, a journal and explicit recovery.
using GraphBridge.EntityFrameworkCore.Schema;
using Microsoft.EntityFrameworkCore;
var manifest = db.Database.GetGraphSchemaManifest(); // offline: what the model needs
Console.WriteLine(manifest.Fingerprint);
var inspection = await db.Database.InspectGraphSchemaAsync(); // metadata reads only
Console.WriteLine(inspection.Comparison.Status); // Pass, Warn or Fail
foreach (var proposal in inspection.Comparison.Proposals.Where(p => p.IsRequired))
Console.WriteLine(proposal.Cypher); // text to review; never executed
On an empty database, the workshop model reports Fail: each node key and the ACTED_IN relationship key lacks its uniqueness constraint. Each missing constraint comes with a proposal such as:
CREATE CONSTRAINT `<name>` FOR (n:`Review`) REQUIRE (n.`id`) IS UNIQUE
What GraphBridge does not do¶
- It runs no DDL on startup, during queries or during
SaveChangesAsync. SaveChangesAsyncchecks, inside its transaction, that a uniqueness constraint covers the complete key of every label and relationship type it writes. If one is missing, it throwsInvalidOperationException("GraphBridge requires an existing uniqueness constraint for :Label (key) ...") and leaves the entries pending. Reconnecting or retrying does not help: deploy the constraint.- Inspection never repairs, drops or replaces schema, and never scans graph data.
- Migrations never run unless you call them.
Storage manifest¶
GetGraphSchemaManifest() builds a deterministic witness of the finalized EF model without touching the database: labels and relationship types, keys, relationship roles and direction, stored properties, concurrency semantics and index intent. Its Fingerprint (SHA-256 of CanonicalJson) ignores CLR names and declaration order, so you can store it with a deployment and compare it later. Opaque value converters and unavailable index facets are listed in Caveats: equal fingerprints do not prove that two arbitrary converters are equivalent. Mapping nodes shows the manifest's members for the workshop model.
Inspect the database schema¶
InspectGraphSchemaAsync(maxSchemaRows = 10_000, cancellationToken) reads the server version and edition (dbms.components()), SHOW CONSTRAINTS and SHOW INDEXES in the database selected by UseNeo4j, and compares them with the manifest. The result has the manifest, the detected Capabilities, the raw Observation and a Comparison with Status, Issues and Proposals.
| Status | Meaning | Effect |
|---|---|---|
Fail |
A required key constraint is missing, or existing schema conflicts with it (for example a range index that occupies the required slot) | Writes to that label or type are refused until you fix it |
Warn |
Something you should decide on: a mapped index is missing, requiredness or property types are enforced by the application rather than the database (always the case on Community), relationship cardinality is checked by the provider, or the database has schema the model does not use | Writes still work |
Pass |
Every expectation is met | None |
Each issue has a Status, a Category (Identity, Existence, PropertyType, Index, Conflict, Capability, UnmanagedSchema, Manifest), a stable Code, a Subject and a Message. Proposals are CREATE CONSTRAINT or CREATE RANGE INDEX text, with IsRequired set for those that unblock writes. On Community they are uniqueness constraints and range indexes; existence and property-type proposals appear only where the detected server supports them (see capability detection).
Inspection borrows an active EF transaction unchanged; otherwise it opens and disposes its own read transaction. If any metadata result exceeds maxSchemaRows, it throws instead of returning a partial report. The cap limits rows, not server work.
Permissions¶
The account needs to see constraints and indexes (SHOW CONSTRAINT and SHOW INDEX privileges) in the selected database. Permission and transport errors propagate as exceptions: missing visibility is never reported as an empty schema. Writers need the same schema visibility, because every save checks its key constraints. The verified Enterprise profiles list exact reader and writer grants. Doctor reports incomplete inspection as GBD-CONN-002.
Apply reviewed schema¶
Read the proposals, decide, and apply them outside the application's request path:
- run the reviewed statements with an administrative account in your deployment tooling, Neo4j Browser or
cypher-shell; or - turn a proposal into a typed migration step, so it is previewed, journaled and applied once:
using GraphBridge.EntityFrameworkCore.Migrations;
var steps = inspection.Comparison.Proposals.Where(p => p.IsRequired)
.Select(p => GraphMigrationStep.CreateConstraint("create-" + p.Name,
GraphMigrationSchemaDefinition.FromProposal(p))); // copies metadata; proposal text is never executed
A uniqueness constraint does not make a property required. Creating one validates existing data and fails if duplicates exist.
Read-only schema drift gate¶
Run inspection as a deployment gate with read-only credentials. The smallest gate fails on Fail and lets warnings through:
var inspection = await db.Database.InspectGraphSchemaAsync(cancellationToken: cancellationToken);
var deploymentExitCode = inspection.Comparison.Status == GraphSchemaStatus.Fail ? 1 : 0; // warnings do not block
The Blogging sample's --check-schema command is a stricter, copyable policy. It emits one JSON report and returns a real process exit code. It never creates constraints, repairs data or runs CRUD, and combining it with write modes is rejected before connecting.
| Exit | Meaning | CI action |
|---|---|---|
| 0 | Required identity constraints and mapped indexes present/online | Continue; review warnings |
| 3 | Drift or unknown required capability | Stop deployment and review report |
| 2 | Configuration, permissions, cancellation or inspection failure | Stop; incomplete inspection is not compatibility |
Application policy required-identities-and-mapped-indexes-v1 promotes mapped-index warnings to blockers. It retains Community existence/type warnings, relationship cardinality caveats and unmanaged-schema warnings. Success is not a data-integrity scan, proof of endpoint cardinality, snapshot isolation or blanket compatibility certification. Metadata may change after inspection.
The versioned JSON includes the observed server version, edition and capabilities, the database, the expected model fingerprint, the policy, issues and blockers. It contains no query values, credentials or exception text. Schema and database names are application metadata: keep reports under appropriate access control. Failure JSON uses stable error categories. Unknown required capabilities fail closed.
From a built checkout, with the fixture settings loaded into the process environment:
dotnet run --project samples/01-Blogging-CodeFirst/Blogging.App --no-build -- --check-schema > schema-report.json
$schemaExit = $LASTEXITCODE
if ($schemaExit -ne 0) { throw "Schema preflight failed with exit $schemaExit; inspect schema-report.json." }
The sample entry point accepts only the disposable localhost fixture. For your deployment, copy SchemaPreflight.cs into your application, pass its configured context and use read-only credentials with metadata visibility. Select the database explicitly. The helper does not read environment variables or own a second model.
A CI step after configuring the context and credentials; schema provisioning stays a separately reviewed step:
- name: Validate graph schema
shell: pwsh
run: |
dotnet run --project samples/01-Blogging-CodeFirst/Blogging.App --no-build -- --check-schema > schema-report.json
$schemaExit = $LASTEXITCODE
if ($schemaExit -ne 0) { throw "Schema preflight exited $schemaExit. Review schema-report.json." }
Doctor runs the same inspection and reports it as GBD-SCHEMA-000 (pass), GBD-SCHEMA-002 (pass with warnings), GBD-SCHEMA-001 (a required expectation fails) or GBD-CAP-001 (capabilities unknown).
Typed migration plans¶
A GraphMigrationPlan is an immutable, ordered list of typed steps with an ID, a predecessor, a checksum and an explicit rollback policy. It accepts no raw Cypher.
using GraphBridge.EntityFrameworkCore.Migrations;
public static GraphMigrationPlan Plan { get; } = new(
"workshop-001-movie-released", predecessorId: null,
[GraphMigrationStep.CreateIndex("released-index",
new GraphMigrationSchemaDefinition("workshop_movie_released", GraphMigrationSchemaKind.RangeIndex, "NODE", "Movie", ["released"]))],
GraphMigrationRollbackPolicy.NoRollback("Dropping the index is a separate, reviewed migration."));
Preview it offline. Preview() returns the checksum and, for each step, its exact Cypher and parameters, impact, required capabilities, precondition and postcondition:
var preview = Plan.Preview();
var journal = GraphMigrationJournal.PreviewInitialization();
CREATE RANGE INDEX `workshop_movie_released` FOR (n:`Movie`) ON (n.`released`)
The journal is three uniqueness constraints on reserved labels; review them before initializing:
CREATE CONSTRAINT `gb_migration_control_key` FOR (n:`__GraphBridgeMigrationControl`) REQUIRE n.`id` IS UNIQUE
CREATE CONSTRAINT `gb_migration_history_key` FOR (n:`__GraphBridgeMigration`) REQUIRE n.`id` IS UNIQUE
CREATE CONSTRAINT `gb_migration_step_key` FOR (n:`__GraphBridgeMigrationStep`) REQUIRE n.`id` IS UNIQUE
Then initialize, validate and apply, as explicit deployment operations:
await db.Database.InitializeGraphMigrationsAsync(cancellationToken: cancellationToken);
var validation = await db.Database.ValidateGraphMigrationAsync(Plan, cancellationToken: cancellationToken);
var applied = await db.Database.ApplyGraphMigrationAsync(Plan, cancellationToken: cancellationToken);
Applying the same plan again returns AlreadyApplied and changes nothing.
Steps¶
| Step | Factory | Behavior |
|---|---|---|
| Create constraint | GraphMigrationStep.CreateConstraint(id, definition) |
Uniqueness, existence or property type (STRING, INTEGER, FLOAT, BOOLEAN). Existence and type need Enterprise |
| Create index | GraphMigrationStep.CreateIndex(id, definition) |
Range index; waits until it is ONLINE, up to IndexWaitTimeout |
| Drop constraint or index | DropConstraint(id, definition, destructiveReason), DropIndex(...) |
The named object must exactly match the expected definition |
| Backfill | Backfill(id, scope, property, value, expectedCount) |
Sets an absent property on exactly expectedCount elements |
| Rename property | RenameProperty(id, scope, from, to, expectedCount, destructiveReason) |
Copies to an absent destination, then removes the source |
| Assert precondition | AssertPrecondition(id, scope, expectedCount) |
Checks a bounded count; changes nothing |
GraphMigrationSchemaDefinition(name, kind, "NODE" or "RELATIONSHIP", labelOrType, properties, propertyType) names one exact schema object. GraphMigrationDataScope(entityType, labelOrType, filters, maxRows) selects elements by label or type and parameterized equality filters, with a cap of 1 to 100,000 candidates. Selected elements are locked and rechecked before they change. The cap bounds candidates and mutations, not the work of finding matches. Concurrent insertions are not fenced: stop application writers when you need a complete label or type snapshot.
Apply contract¶
| Rule | Detail |
|---|---|
| Journal | InitializeGraphMigrationsAsync creates and verifies the journal. ValidateGraphMigrationAsync reads it, and the first unfinished step, without writing; check CanApply, Issues, AlreadyApplied, OwnerToken and OwnerGeneration |
| Order | Plans form a chain: a plan's predecessorId must equal the recorded head (null for the first plan) |
| Checksums | A completed plan replays as a no-op only with the same checksum. A changed plan under an existing ID is refused |
| Atomicity | Each data step and its completion record commit together. Each schema step uses separate prepared, schema and completion transactions, because Neo4j cannot mix schema and data writes in one transaction. The whole plan is not atomic |
| Ownership | One migrator owns the journal at a time. Ownership has no expiry: a failed run keeps it until explicit recovery |
| Unrecorded schema | A step does not adopt a constraint or index that its journal did not create; equivalent or conflicting existing schema needs review |
| Transactions | Migration calls are refused inside an active EF transaction. Use a fresh context afterwards, because native changes do not refresh tracked entities |
| Bounds | GraphMigrationOptions(transactionTimeout, indexWaitTimeout, maxSchemaRows) defaults to 2 minutes, 30 seconds and 10,000 rows |
There is no automatic retry, compensation or Down. Concurrent journal initialization can return Neo4j's deadlock error to one caller; rerun initialization explicitly and it verifies the exact stored definitions. The design-time commands list, preview and report the status of plans that an application declares; they never initialize or apply them.
Migration recovery example¶
Steps commit separately, so a failed plan can leave completed changes and an owner in the journal. Recovery finishes the plan from that state. It does not turn several transactions into one atomic migration or compensate for completed steps.
The Organization sample has two real plans: organization_sample_schema_v1 (three uniqueness constraints) and organization_sample_employment_v2, whose predecessor is the first. Preview both without a database, from a built checkout:
dotnet run --project samples/02-Organization-OntologyFirst/Organization.App --no-build -- --preview
Every statement, parameter and precondition is printed, with each plan's checksum. The sample's parameters are public constants; previews of your own plans contain application data. The compiled plans restrict every v2 data step to one exact employment ID and at most one relationship. They backfill department, then copy role to an absent position before removing role. Review the output and the no-rollback policy before running the sample's explicit --prepare-schema and --migrate-demo modes; the sample instructions describe the disposable fixture and ownership guard. Preview checks the plan, not the database; ValidateGraphMigrationAsync is the separate read-only observation.
Recovery takes the exact observed owner token and generation, and your declaration that the previous process is stopped or fenced and its outstanding transactions are resolved:
using GraphBridge.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore;
// db is a fresh GraphBridge DbContext; plan is the identical reviewed plan.
// Call only AFTER stopping/fencing the former worker and resolving its transactions.
var failed = await db.Database.ValidateGraphMigrationAsync(plan);
if (failed.OwnerToken is null)
throw new InvalidOperationException("There is no abandoned owner to recover.");
var recovery = new GraphMigrationRecovery(
failed.OwnerToken, failed.OwnerGeneration,
priorOwnerQuiesced: true,
reason: "Prior worker stopped; transactions resolved and durable journal reviewed.");
var recovered = await db.Database.RecoverGraphMigrationAsync(plan, recovery);
Do not put this in a general exception handler that always declares the prior owner quiescent. The declaration must be true: a generation number cannot fence a paused process that later sends DDL, and an old timestamp does not authorize takeover. GraphMigrationRecovery rejects priorOwnerQuiesced: false. Changed checksums under an existing ID, stale generations, conflicting schema and unresolved preconditions need review. Never erase the journal to make a retry succeed.
| Failure point | What recovery does |
|---|---|
| Preview or validation | Nothing to recover: neither writes the journal or schema |
| A data step fails before commit | The data change and its completion record roll back together; recovery runs the step once |
| Interrupted after a data step committed | The data and its journal record committed together; recovery recognizes the step and does not backfill twice |
| A schema step was prepared, then interrupted | If the exact postcondition holds, completion is recorded without repeating the DDL; if the exact precondition holds, the step runs; otherwise it is a conflict, and recovery never drops and recreates schema automatically |
Limits¶
- No whole-plan atomicity, automatic retry, ownership expiry, compensation or
Down. - No
dotnet ef migrationsintegration and no arbitrary data scripts or procedures; data changes are typed, bounded steps. - Enterprise existence and type proposals, and Graph Type observation, have unit evidence only; no Enterprise schema deployment is claimed.
- Inspection is not a data scan: a passing report does not prove there are no duplicates, missing values or wrong relationship counts.
- Recovery is exercised at deterministic checkpoints around real Neo4j transactions, not with network disconnects. For uncertain
SaveChangesAsynccommits, see transactions and concurrency.
Try it¶
- Workshop lessons 03 · Schema before data, 23 · Migration plans and 24 · Deployment preflight and command tracing.
- Samples 01 Blogging (
--check-schema), 02 Organization (plans and recovery) and 04 Design-time.
Evidence: version and capability matrix, schema tests, migration tests. Decisions: ADR-0017, ADR-0018.