weftgraph 0.1.1

Graph Storage gear: typed, multi-tenant knowledge graph with search and traversal over a pluggable store
Documentation
//! Initial schema (DESIGN ยง 3.7).
//!
//! `PostgreSQL` only: tsvector, `JSONB` indexing and pgvector are load-bearing,
//! so there is no portable fallback. Every key contract carries `tenant_id`
//! from day one โ€” composite primary keys, tenant-scoped uniqueness,
//! tenant-carrying foreign keys โ€” so partitioning at scale stays a physical
//! reorganisation, not an identity migration. Every read-path index is
//! partial on `deleted_at IS NULL` (Soft Delete Contract).

use sea_orm_migration::prelude::*;
use sea_orm_migration::sea_orm::ConnectionTrait;

use super::FTS_CONFIG;

/// Vector width of the deployment's single embedding space. Fixed at
/// migration time; readiness verifies the configured dimension matches.
pub const EMBEDDING_DIMENSION: u32 = 384;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        let backend = manager.get_database_backend();
        if backend != sea_orm::DatabaseBackend::Postgres {
            return Err(DbErr::Custom(format!(
                "graph-storage requires PostgreSQL; refusing to migrate {backend:?}"
            )));
        }

        manager
            .get_connection()
            .execute_unprepared(&format!(
                r"
CREATE EXTENSION IF NOT EXISTS vector SCHEMA public;

-- Interned GTS types: a cache of the authoritative registry with a foreign
-- identity. Types are rows, not enums: registration is an API call.
CREATE TABLE IF NOT EXISTS gts_type (
    tenant_id        UUID        NOT NULL,
    id               INTEGER     NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    gts_type_uuid    UUID        NOT NULL,
    gts_type_id      TEXT        NOT NULL,
    kind             TEXT        NOT NULL CHECK (kind IN ('node', 'edge', 'attribute')),
    type_schema      JSONB       NOT NULL DEFAULT '{{}}'::jsonb,
    effective_traits JSONB       NOT NULL DEFAULT '{{}}'::jsonb,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, id),
    UNIQUE (tenant_id, gts_type_uuid),
    UNIQUE (tenant_id, gts_type_id)
);

CREATE TABLE IF NOT EXISTS node (
    tenant_id            UUID        NOT NULL,
    id                   BIGINT      NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    node_key             TEXT        NOT NULL,
    gts_node_type_id     INTEGER     NOT NULL,
    name                 TEXT        NOT NULL DEFAULT '',
    payload              JSONB       NOT NULL DEFAULT '{{}}'::jsonb,
    search_text          TEXT        NOT NULL DEFAULT '',
    search               TSVECTOR    GENERATED ALWAYS AS (to_tsvector('{FTS_CONFIG}', search_text)) STORED,
    embedding            VECTOR({EMBEDDING_DIMENSION}),
    embedding_epoch      BIGINT,
    embedding_input_hash TEXT,
    source_namespace     TEXT,
    owner_principal      TEXT        NOT NULL DEFAULT '',
    created_by           TEXT        NOT NULL DEFAULT '',
    version              BIGINT      NOT NULL DEFAULT 1,
    created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at           TIMESTAMPTZ,
    PRIMARY KEY (tenant_id, id),
    UNIQUE (tenant_id, node_key),
    FOREIGN KEY (tenant_id, gts_node_type_id) REFERENCES gts_type (tenant_id, id)
);

-- Endpoint foreign keys are RESTRICT, never CASCADE: deleting a static node
-- must not silently destroy analysis edges attached to it. Note: PostgreSQL
-- 18+ reports a RESTRICT refusal as SQLSTATE 23001 (restrict_violation),
-- not 23503 โ€” the error classifier accepts both.
CREATE TABLE IF NOT EXISTS edge (
    tenant_id        UUID        NOT NULL,
    id               BIGINT      NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    edge_key         TEXT        NOT NULL,
    gts_edge_type_id INTEGER     NOT NULL,
    src_node_id      BIGINT      NOT NULL,
    dst_node_id      BIGINT      NOT NULL,
    discriminator    TEXT,
    payload          JSONB       NOT NULL DEFAULT '{{}}'::jsonb,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at       TIMESTAMPTZ,
    PRIMARY KEY (tenant_id, id),
    UNIQUE (tenant_id, edge_key),
    FOREIGN KEY (tenant_id, gts_edge_type_id) REFERENCES gts_type (tenant_id, id),
    FOREIGN KEY (tenant_id, src_node_id) REFERENCES node (tenant_id, id) ON DELETE RESTRICT,
    FOREIGN KEY (tenant_id, dst_node_id) REFERENCES node (tenant_id, id) ON DELETE RESTRICT
);

-- Per-tenant metadata. Two normative keys: graph_revision (per tenant) and
-- source_epoch (deployment-wide, stored under the nil tenant).
CREATE TABLE IF NOT EXISTS graph_meta (
    tenant_id UUID  NOT NULL,
    key       TEXT  NOT NULL,
    value     JSONB NOT NULL,
    PRIMARY KEY (tenant_id, key)
);

-- Idempotency receipts commit in the same transaction as the batch.
CREATE TABLE IF NOT EXISTS ingest_idempotency (
    tenant_id       UUID        NOT NULL,
    producer        TEXT        NOT NULL,
    idempotency_key TEXT        NOT NULL,
    request_hash    TEXT        NOT NULL,
    source_epoch    BIGINT      NOT NULL,
    graph_revision  BIGINT      NOT NULL,
    response        JSONB       NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, producer, idempotency_key)
);

-- Scope replacement registry: the single-writer lock row and the generation
-- fence per canonical scope identity.
CREATE TABLE IF NOT EXISTS scope_registry (
    tenant_id       UUID        NOT NULL,
    scope_attribute TEXT        NOT NULL,
    scope_value     TEXT        NOT NULL,
    owner_producer  TEXT        NOT NULL,
    generation      BIGINT      NOT NULL,
    request_hash    TEXT        NOT NULL,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, scope_attribute, scope_value)
);

-- Traversal backbone: one composite index per direction, live rows only.
CREATE INDEX IF NOT EXISTS idx_edge_src
    ON edge (tenant_id, src_node_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_edge_dst
    ON edge (tenant_id, dst_node_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_edge_type
    ON edge (tenant_id, gts_edge_type_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_node_type
    ON node (tenant_id, gts_node_type_id) WHERE deleted_at IS NULL;

-- Lexical arm: GIN over the generated tsvector, live rows only.
CREATE INDEX IF NOT EXISTS idx_node_search
    ON node USING gin (search) WHERE deleted_at IS NULL;

-- Vector arm: HNSW cosine, live embedded rows only.
CREATE INDEX IF NOT EXISTS idx_node_embedding
    ON node USING hnsw (embedding vector_cosine_ops)
    WHERE deleted_at IS NULL AND embedding IS NOT NULL;
                ",
            ))
            .await?;
        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .get_connection()
            .execute_unprepared(
                "DROP TABLE IF EXISTS scope_registry; \
                 DROP TABLE IF EXISTS ingest_idempotency; \
                 DROP TABLE IF EXISTS graph_meta; \
                 DROP TABLE IF EXISTS edge; \
                 DROP TABLE IF EXISTS node; \
                 DROP TABLE IF EXISTS gts_type;",
            )
            .await?;
        Ok(())
    }
}