Schema Management (Indexes & Constraints)
Drivine manages vector indexes, full-text indexes, range indexes, and uniqueness constraints across Neo4j, Memgraph, FalkorDB and Oracle — with idempotent, drift-aware ensure semantics and engine differences (DDL syntax, introspection, FalkorDB’s Redis-command constraints) handled for you. Opt-in: declare nothing, pay nothing.
Imperative API
Section titled “Imperative API”Every PersistenceManager exposes indexes and constraints managers. Operations always run in auto-commit mode (schema DDL cannot run inside a data transaction):
// Idempotent — call on every startupval result = persistenceManager.indexes.ensure( VectorIndexSpec(label = "Proposition", property = "embedding", dimensions = 1536))persistenceManager.indexes.ensure(RangeIndexSpec("Proposition", "contextId"))persistenceManager.indexes.ensure(RangeIndexSpec("Message", listOf("sessionId", "createdAt"))) // compositepersistenceManager.indexes.ensure(FullTextIndexSpec("Chunk", "text")) // full-text (single property)persistenceManager.indexes.ensure(FullTextIndexSpec("Entity", listOf("name", "description"))) // full-text (multi-property)
persistenceManager.constraints.ensure(UniquenessConstraintSpec("ChatSession", "sessionId"))persistenceManager.constraints.ensure(UniquenessConstraintSpec("Membership", listOf("tenantId", "userId")))ensure returns an EnsureResult:
| Result | Meaning |
|---|---|
Created |
Nothing existed and the item was created, or it existed without part of what the engine makes for it (on Oracle, a range index without its index over the label’s nodes) and that part was made |
AlreadyMatching |
A matching item exists; nothing changed |
Drift |
An item exists with a different shape (e.g. vector dimensions changed). Nothing changed — call recreate(spec) to replace it (destructive) |
Violation |
Constraint only: existing data violates it. Includes a bounded sample of the conflicting values |
Recreated |
From an explicit recreate(spec) — old item dropped, new one created |
Declarative: SchemaCatalog bean
Section titled “Declarative: SchemaCatalog bean”Register a SchemaCatalog bean and Drivine ensures everything on startup (indexes before constraints):
@Beanfun propositionSchema(embeddingService: EmbeddingService) = SchemaCatalog.of( VectorIndexSpec("Proposition", "embedding", embeddingService.dimensions), RangeIndexSpec("Proposition", "contextId"), UniquenessConstraintSpec("Proposition", "id"),)Multiple catalog beans merge; identical declarations deduplicate; conflicting declarations for the same (kind, label, properties) fail startup.
Which databases a catalog applies to. By default a catalog broadcasts to every schema-capable registered database — engines without DDL support (Neptune, openCypher) are skipped with a warning. Narrow it when you need to:
SchemaCatalog.of(...) // all schema-capable databases (default)SchemaCatalog.of(...).forDefaultDatabase() // only the primary (first-registered) datasourceSchemaCatalog.of(...).forDatabase("users") // one named datasourceSchemaCatalog.of(...).forDatabases("a", "b") // a specific setBroadcast is lenient (skips engines that can’t do schema); an explicitly named target is strict — pointing it at an unknown or schema-incapable datasource fails startup. "default" resolves to the first-registered datasource, consistent with the rest of Drivine.
Targeting is replace/last-wins, not additive — use one forDatabases("a", "b") call to target several; chaining forDatabase(...) calls does not accumulate (last wins).
Declarative: annotations on fragments
Section titled “Declarative: annotations on fragments”@NodeFragment(labels = ["Proposition"])data class PropositionNode( @NodeId @RangeIndex @Unique val id: String,
@RangeIndex val contextId: String,
val text: String,
@VectorIndex(similarity = SimilarityFunction.COSINE) val embedding: List<Float>?,
@FullTextIndex // full-text index on this property (also drives loadMatching) val body: String,)
// Composite declarations go on the class@NodeFragment(labels = ["Message"])@RangeIndex(properties = ["sessionId", "createdAt"])@Unique(properties = ["sessionId", "sequence"])@FullTextIndex(properties = ["subject", "body"]) // multi-property full-text indexdata class MessageNode(/* ... */)Scan them into a catalog — vector dimensions come from your embedding model at runtime, via a VectorDimensionProvider:
@Beanfun schema(embeddingService: EmbeddingService) = SchemaCatalog.fromFragments( VectorDimensionProvider { _, _ -> embeddingService.dimensions }, PropositionNode::class, MessageNode::class,)Works for Java fragments too (SchemaCatalog.fromFragments(JavaNode.class)).
Runtime enforcement (SchemaManager)
Section titled “Runtime enforcement (SchemaManager)”Catalogs are applied by a SchemaManager bean, enforced once on startup. It’s also injectable, so you can drive schema changes at runtime — e.g. build indexes after a bulk load, or rebuild after re-embedding:
@Componentclass Reindexer(private val schema: SchemaManager) { fun afterBulkLoad() { schema.enforce() // idempotent: create what's missing, recreate on version change } fun afterReembed() { schema.recreateAll() // brute-force: drop + recreate every declared item }}enforce() is safe to call repeatedly; recreateAll() is the destructive hammer. (drivine.schema.enabled=false disables only the startup run — the bean is still there for runtime calls.)
Version-triggered rebuild
Section titled “Version-triggered rebuild”Drift detection only catches changes introspection can see (dimensions, similarity, properties). A change it can’t see — swapping the embedding model for one with the same dimensions — leaves stale vectors behind. Tag a catalog with a version token to force a one-time rebuild when that token changes:
@Beanfun chunkSchema(embeddingService: EmbeddingService) = SchemaCatalog.of( VectorIndexSpec("Chunk", "embedding", embeddingService.dimensions),).withVersion(embeddingService.modelId) // bump this → recreate onceHow it works: the last-applied token is stored in a reserved _DrivineSchema marker node per database (portable across the engines; on Oracle it is a row of the graph’s node table). On enforce(), a changed token drops and recreates that catalog’s items, then records the new token; a first-ever token is adopted without recreating, so turning versioning on never nukes a healthy schema.
Recreating a vector index rebuilds it from the stored embedding properties — it does not re-embed. After a model swap, re-embed the nodes first (same dimensions → the index even auto-updates), then
enforce()/recreateAll()for the structural half. Drivine manages schema, not your embeddings.
Configuration
Section titled “Configuration”drivine: schema: enabled: true # master switch for startup initialization mode: FAIL_FAST # FAIL_FAST (default) or WARN on drift/violations recreate-on-drift: false # destructive: rebuild items whose shape changed recreate-on-startup: false # destructive: rebuild everything every startup violation-sample-size: 10 # conflicting rows sampled on constraint violationsPer-engine notes
Section titled “Per-engine notes”| Neo4j | Memgraph | FalkorDB | |
|---|---|---|---|
| Vector indexes | CREATE VECTOR INDEX … IF NOT EXISTS |
WITH CONFIG {…}, uSearch metrics |
OPTIONS {…}, unnamed |
| Full-text indexes | CREATE FULLTEXT INDEX … ON EACH […] (+ analyzer) |
CREATE TEXT INDEX … ON :L(props) |
CREATE FULLTEXT INDEX FOR (n:L) ON (n.prop), per-property, unnamed |
| Range indexes | Named, composite supported | Label-property style | Per-label coverage; extended incrementally |
| Uniqueness | REQUIRE … IS UNIQUE |
ASSERT … IS UNIQUE |
Redis command GRAPH.CONSTRAINT (not Cypher) — Drivine issues it at driver level, auto-creates the required backing index, and polls the asynchronous build |
| Item names | Yes | Vector only | No |
Oracle. Every item is an index over an expression, since a property is a path into a JSON column, and what Drivine made is recorded in the table <graph>_schema, which ensure and drift detection read. An item whose index or table was dropped by hand is made again by the next ensure.
| Oracle | |
|---|---|
| Vector indexes | A table of embeddings (VECTOR(n, FLOAT32)) beside the nodes, kept in step by a trigger and filled from the nodes there are, with CREATE VECTOR INDEX … ORGANIZATION NEIGHBOR PARTITIONS over it. An embedding of another dimension leaves its node out of the index |
| Full-text indexes | A table of the indexed text beside the nodes, kept in step by a trigger, with an Oracle Text search index over it. Without Oracle Text the index is recorded and nothing is made |
| Range indexes | Two indexes. CREATE INDEX … (JSON_VALUE(props, '$.p')), the property read as text, over the property whatever the label, so two labels that index a property of one name share it: a save and a load by id find a node by it. And CREATE INDEX … (CASE WHEN JSON_EXISTS(n.labels, '$[*]?(@ == "Label")') THEN JSON_VALUE(n.props, '$.p') END), over the property of the label’s nodes alone, which a filter on the label’s nodes reads. Composite supported |
| Uniqueness | A unique index over the property for the nodes that carry the label, with the range index a node is found by. A node that lacks one of the properties is not constrained. Over one property, the unique index stands for the range index’s index over the label’s nodes, and none is made beside it |
| Item names | Recorded; the Oracle object’s name is derived from the graph, label and properties, and recorded with it. A drop drops the object the record names |
The store’s identity (DrivineStore) and the schema marker are nodes of the graph on Oracle too, and the graph’s tables are made for them when they are the first thing asked of a new graph.
Neptune and generic openCypher have no schema management support — operations fail loudly rather than silently no-op.

