Autogenerate

runic can compare a desired schema described in Python (the SchemaManifest) with the actual schema currently in your graph and generate migration scripts for the difference — no hand-written Cypher required.


How it works

  1. You declare the target schema as a SchemaManifest object and pass it to context.configure() in env.py.

  2. runic revision --autogenerate calls CALL db.indexes() and CALL db.constraints() to read the live schema, then diffs it against the manifest.

  3. It generates an upgrade/downgrade body and writes a new revision file marked # AUTOGENERATED review before applying; cannot detect renames.

  4. Always review the generated file before applying it. Autogenerate cannot detect renames and may generate incorrect drop + create pairs in place of a rename (see Limitations).

Declaring a SchemaManifest

Import the manifest classes from runic:

from runic.migrate.manifest import (
    FulltextIndex,
    MandatoryConstraint,
    RangeIndex,
    SchemaManifest,
    UniqueConstraint,
    VectorIndex,
)

Build the manifest:

TARGET = SchemaManifest(
    range_indexes=[
        RangeIndex("Person", "email"),
        RangeIndex("Person", "name"),
        RangeIndex("KNOWS", "since", rel=True),
    ],
    fulltext_indexes=[
        FulltextIndex("Article", ("title", "body")),
        FulltextIndex("Review", ("text",), language="german"),
    ],
    vector_indexes=[
        VectorIndex("Document", "embedding", dimension=1536, similarity="cosine"),
    ],
    constraints=[
        UniqueConstraint("NODE", "Person", ["email"]),
        MandatoryConstraint("NODE", "Person", ["name"]),
    ],
)

Configuring env.py for autogenerate

Pass target_manifest to context.configure():

# runic/env.py
import os
from runic.migrate import context
from runic.migrate.adapters import create_adapter
from runic.migrate.manifest import RangeIndex, SchemaManifest, UniqueConstraint

TARGET = SchemaManifest(
    range_indexes=[RangeIndex("Person", "email")],
    constraints=[UniqueConstraint("NODE", "Person", ["email"])],
)

adapter = create_adapter(
    "falkordb",
    url=os.getenv("FALKORDB_URL", "falkor://localhost:6379"),
    graph_name=os.getenv("FALKORDB_GRAPH", "my_graph"),
)
context.configure(adapter, target_manifest=TARGET)

Generating a migration script

Run runic revision --autogenerate:

$ runic revision --autogenerate -m "add person constraints"
Created revision: runic/versions/b2c3d4e5_add_person_constraints.py  [CANDIDATE  review before applying]

Open the generated file:

def upgrade(op) -> None:
    # AUTOGENERATED — review before applying; cannot detect renames
    op.create_range_index("Person", "email")
    op.create_constraint("UNIQUE", "NODE", "Person", ["email"])

def downgrade(op) -> None:
    # AUTOGENERATED — review before applying; cannot detect renames
    op.drop_constraint("UNIQUE", "NODE", "Person", ["email"])
    op.drop_range_index("Person", "email")

Important

The [CANDIDATE review before applying] notice in the CLI output is a reminder that the generated file must be reviewed before being applied. Never apply an autogenerated migration to production without reviewing it.

Using runic check in CI

runic check exits with code 1 if the live schema has drifted from target_manifest:

$ runic check
Schema up-to-date.

$ runic check
Pending schema changes (run `runic revision --autogenerate -m "..."` to generate):
  + op.create_range_index("Order", "placed_at")

Add it to your CI pipeline to fail the build when uncommitted schema changes are present:

# GitHub Actions example
- name: Check schema drift
  run: |
    FALKORDB_URL=${{ secrets.FALKORDB_URL }} \
    FALKORDB_GRAPH=${{ secrets.FALKORDB_GRAPH }} \
    runic check

What autogenerate detects

Autogenerate compares canonical keys for each schema object type:

Object type

Key

Range index

entity type, label, property

Fulltext index

label, properties (tuple)

Vector index

label, property

Constraint

kind (UNIQUE/MANDATORY), entity, label, properties (tuple)

Drop order in the generated upgrade follows the dependency rule: constraints first, then indexes. Create order is the reverse: indexes first, constraints second (because UNIQUE constraints require a prior range index).

What autogenerate does NOT detect

See Limitations for a full list. Key gaps:

  • Renames — a renamed property or label appears as drop + create, which would delete and recreate the index but would not rename data.

  • Property type changes — FalkorDB does not expose property type information via db.indexes(); runic cannot diff on type.

  • Node/relationship data — only schema objects (indexes and constraints) are compared.

  • Index options drift — changes to HNSW parameters (m, ef_construction) on an existing vector index are not detected.

Manifest classes reference

class runic.migrate.manifest.SchemaManifest(range_indexes: 'list[RangeIndex]' = <factory>, fulltext_indexes: 'list[FulltextIndex]' = <factory>, vector_indexes: 'list[VectorIndex]' = <factory>, constraints: 'list[UniqueConstraint | MandatoryConstraint]' = <factory>)[source]

Bases: object

Parameters:
class runic.migrate.manifest.RangeIndex(label: 'str', prop: 'str', rel: 'bool' = False)[source]

Bases: object

Parameters:
class runic.migrate.manifest.FulltextIndex(label: 'str', props: 'list[str] | tuple[str, ...]', language: 'str | None' = None, stopwords: 'list[str] | tuple[str, ...] | None' = None) 'None'[source]

Bases: object

Parameters:
class runic.migrate.manifest.VectorIndex(label: 'str', prop: 'str', dimension: 'int', similarity: 'str', m: 'int' = 16, ef_construction: 'int' = 200, ef_runtime: 'int' = 10)[source]

Bases: object

Parameters:
class runic.migrate.manifest.UniqueConstraint(entity: 'str', label: 'str', props: 'list[str] | tuple[str, ...]') 'None'[source]

Bases: object

Parameters:
class runic.migrate.manifest.MandatoryConstraint(entity: 'str', label: 'str', props: 'list[str] | tuple[str, ...]') 'None'[source]

Bases: object

Parameters: