Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,461 changes: 1,461 additions & 0 deletions docs/superpowers/plans/2026-06-27-schema-registry-and-versioning.md

Large diffs are not rendered by default.

263 changes: 263 additions & 0 deletions docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions extralit-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ dependencies = [
"ocrmypdf>=16.11.0",
"pdf2image>=1.17.0",
"opencv-python-headless>=4.11.0.86",
"pandera[io]>=0.20",
]

[project.optional-dependencies]
Expand Down
2 changes: 2 additions & 0 deletions extralit-server/src/extralit_server/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from extralit_server import helpers
from extralit_server._version import __version__ as extralit_version
from extralit_server.api.routes import api_v1
from extralit_server.api.v2 import api_v2
from extralit_server.constants import DEFAULT_API_KEY, DEFAULT_PASSWORD, DEFAULT_USERNAME
from extralit_server.contexts import accounts, files
from extralit_server.database import get_async_db
Expand Down Expand Up @@ -210,6 +211,7 @@ async def add_server_timing_header(request: Request, call_next):
def configure_api_router(app: FastAPI):
"""Configures and set the api router to app"""
app.mount("/api/v1", api_v1)
app.mount("/api/v2", api_v2)


def configure_telemetry(app: FastAPI):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""create schema and schema_version tables

Revision ID: 9f3010c649c8
Revises: 54d65879a68e
Create Date: 2026-06-27 17:00:36.438902

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "9f3010c649c8"
down_revision = "54d65879a68e"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"schemas",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("kind", sa.Enum("singleton", "table", name="schema_kind_enum"), nullable=False),
sa.Column("status", sa.Enum("draft", "published", name="schema_status_enum"), nullable=False),
sa.Column("current_version_id", sa.Uuid(), nullable=True),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("workspace_id", sa.Uuid(), nullable=False),
sa.Column("inserted_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("workspace_id", "name", name="schema_workspace_id_name_uq"),
)
op.create_index(op.f("ix_schemas_name"), "schemas", ["name"], unique=False)
op.create_index(op.f("ix_schemas_status"), "schemas", ["status"], unique=False)
op.create_index(op.f("ix_schemas_workspace_id"), "schemas", ["workspace_id"], unique=False)

op.create_table(
"schema_versions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("schema_id", sa.Uuid(), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("object_key", sa.Text(), nullable=False),
sa.Column("object_version_id", sa.Text(), nullable=True),
sa.Column("etag", sa.String(), nullable=False),
sa.Column("checksum", sa.String(), nullable=False),
sa.Column("parent_version_id", sa.Uuid(), nullable=True),
sa.Column("columns_cache", sa.JSON(), nullable=False),
sa.Column("review_widgets", sa.JSON(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("inserted_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["schema_id"], ["schemas.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["parent_version_id"], ["schema_versions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("schema_id", "version", name="schema_version_schema_id_version_uq"),
)
op.create_index(op.f("ix_schema_versions_schema_id"), "schema_versions", ["schema_id"], unique=False)
op.create_index(op.f("ix_schema_versions_version"), "schema_versions", ["version"], unique=False)

# Deferred FK: schemas.current_version_id -> schema_versions.id (created after both tables exist).
# SQLite cannot ALTER-add a constraint; the column carries no DB-level FK there (model behaviour
# is unaffected and the test suite runs on SQLite). Postgres gets the real constraint.
if op.get_bind().dialect.name != "sqlite":
op.create_foreign_key(
"schema_current_version_id_fk",
"schemas",
"schema_versions",
["current_version_id"],
["id"],
ondelete="SET NULL",
)


def downgrade() -> None:
if op.get_bind().dialect.name != "sqlite":
op.drop_constraint("schema_current_version_id_fk", "schemas", type_="foreignkey")
op.drop_index(op.f("ix_schema_versions_version"), table_name="schema_versions")
op.drop_index(op.f("ix_schema_versions_schema_id"), table_name="schema_versions")
op.drop_table("schema_versions")
op.drop_index(op.f("ix_schemas_workspace_id"), table_name="schemas")
op.drop_index(op.f("ix_schemas_status"), table_name="schemas")
op.drop_index(op.f("ix_schemas_name"), table_name="schemas")
op.drop_table("schemas")
sa.Enum(name="schema_kind_enum").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="schema_status_enum").drop(op.get_bind(), checkfirst=True)
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from extralit_server.api.policies.v1.question_policy import QuestionPolicy
from extralit_server.api.policies.v1.record_policy import RecordPolicy
from extralit_server.api.policies.v1.response_policy import ResponsePolicy
from extralit_server.api.policies.v1.schema_policy import SchemaPolicy
from extralit_server.api.policies.v1.suggestion_policy import SuggestionPolicy
from extralit_server.api.policies.v1.user_policy import UserPolicy
from extralit_server.api.policies.v1.vector_settings_policy import VectorSettingsPolicy
Expand All @@ -25,6 +26,7 @@
"QuestionPolicy",
"RecordPolicy",
"ResponsePolicy",
"SchemaPolicy",
"SuggestionPolicy",
"UserPolicy",
"VectorSettingsPolicy",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from uuid import UUID

from extralit_server.api.policies.v1.commons import PolicyAction
from extralit_server.models import User
from extralit_server.models.v2 import Schema


class SchemaPolicy:
@classmethod
def list(cls, workspace_id: UUID) -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or await actor.is_member(workspace_id)

return is_allowed

@classmethod
def create(cls, workspace_id: UUID) -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or (actor.is_admin and await actor.is_member(workspace_id))

return is_allowed

@classmethod
def get(cls, schema: Schema) -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or await actor.is_member(schema.workspace_id)

return is_allowed

@classmethod
def update(cls, schema: Schema) -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or (actor.is_admin and await actor.is_member(schema.workspace_id))

return is_allowed

@classmethod
def delete(cls, schema: Schema) -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or (actor.is_admin and await actor.is_member(schema.workspace_id))

return is_allowed

@classmethod
def publish(cls, schema: Schema) -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or (actor.is_admin and await actor.is_member(schema.workspace_id))

return is_allowed
Empty file.
62 changes: 62 additions & 0 deletions extralit-server/src/extralit_server/api/schemas/v2/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from datetime import datetime
from typing import Any
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, constr

from extralit_server.enums import SchemaKind, SchemaStatus

SchemaName = constr(min_length=1, max_length=200)


class SchemaCreate(BaseModel):
name: SchemaName
kind: SchemaKind = SchemaKind.table
workspace_id: UUID
settings: dict[str, Any] = Field(default_factory=dict)


class SchemaUpdate(BaseModel):
name: SchemaName | None = None
settings: dict[str, Any] | None = None


class SchemaVersionCreate(BaseModel):
body: str = Field(..., description="Pandera DataFrameSchema serialized via .to_json()")
# Per-column review widgets carried out-of-band (column name -> widget config); see spec §13.
# Pandera's to_json drops Column.metadata, so the review widget cannot live in `body`.
review_widgets: dict[str, dict[str, Any]] = Field(default_factory=dict)


class SchemaVersionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: UUID
schema_id: UUID
version: int
object_key: str
object_version_id: str | None
etag: str
checksum: str
parent_version_id: UUID | None
columns_cache: list[dict[str, Any]]
review_widgets: dict[str, dict[str, Any]]
inserted_at: datetime


class SchemaRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: UUID
name: str
kind: SchemaKind
status: SchemaStatus
current_version_id: UUID | None
settings: dict[str, Any]
workspace_id: UUID
inserted_at: datetime
updated_at: datetime


class Schemas(BaseModel):
items: list[SchemaRead]
27 changes: 27 additions & 0 deletions extralit-server/src/extralit_server/api/v2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from fastapi import FastAPI

from extralit_server._version import __version__ as extralit_version
from extralit_server.api.errors.v1.exception_handlers import add_exception_handlers as add_exception_handlers_v1
from extralit_server.api.handlers.v1 import authentication as authentication_v1
from extralit_server.api.v2 import schemas as schemas_v2
from extralit_server.errors.base_errors import __ALL__
from extralit_server.errors.error_handler import APIErrorHandler


def create_api_v2() -> FastAPI:
api_v2 = FastAPI(
title="Extralit v2",
description="Extralit Server API v2 (schema-centric)",
version=str(extralit_version),
responses={error.HTTP_STATUS: error.api_documentation() for error in __ALL__},
)
APIErrorHandler.configure_app(api_v2)
add_exception_handlers_v1(api_v2)

# Auth endpoints are reused from v1 so v2 tokens work identically.
api_v2.include_router(authentication_v1.router)
api_v2.include_router(schemas_v2.router)
return api_v2


api_v2 = create_api_v2()
Loading
Loading