fractafrag/services/api/app/models/models.py
John Lightner 1047a1f5fe Versioning, drafts, resizable editor, My Shaders, 200 seed shaders
Architecture — Shader versioning & draft system:
- New shader_versions table: immutable snapshots of every edit
- Shaders now have status: draft, published, archived
- current_version counter tracks version number
- Every create/update creates a ShaderVersion record
- Restore-from-version endpoint creates new version (never destructive)
- Drafts are private, only visible to author
- Forks start as drafts
- Free tier rate limit applies only to published shaders (drafts unlimited)

Architecture — Platform identity:
- System account 'fractafrag' (UUID 00000000-...-000001) created in init.sql
- is_system flag on users and shaders
- system_label field: 'fractafrag-curated', future: 'fractafrag-generated'
- Feed/explore can filter by is_system
- System shaders display distinctly from user/AI content

API changes:
- GET /shaders/mine — user workspace (drafts, published, archived)
- GET /shaders/{id}/versions — version history
- GET /shaders/{id}/versions/{n} — specific version
- POST /shaders/{id}/versions/{n}/restore — restore old version
- POST /shaders accepts status: 'draft' | 'published'
- PUT /shaders/{id} accepts change_note for version descriptions
- PUT status transitions: draft→published, published→archived, archived→published

Frontend — Editor improvements:
- Resizable split pane with drag handle (20-80% range, smooth col-resize cursor)
- Save Draft button (creates/updates as draft, no publish)
- Publish button (validates, publishes, redirects to shader page)
- Version badge shows current version number when editing existing
- Owner detection: editing own shader vs forking someone else's
- Saved status indicator ('Draft saved', 'Published')

Frontend — My Shaders workspace:
- /my-shaders route with status tabs (All, Draft, Published, Archived)
- Count badges per tab
- Status badges on shader cards (draft=yellow, published=green, archived=grey)
- Version badges (v1, v2, etc.)
- Quick actions: Edit, Publish, Archive, Restore, Delete per status
- Drafts link to editor, published link to detail page

Seed data — 200 fractafrag-curated shaders:
- 171 2D + 29 3D shaders
- 500 unique tags across all shaders
- All 200 titles are unique
- Covers: fractals (Mandelbrot, Julia sets), noise (fbm, Voronoi, Perlin),
  raymarching (metaballs, terrain, torus knots, metall/glass),
  effects (glitch, VHS, plasma, aurora, lightning, fireworks),
  patterns (circuit, hex grid, stained glass, herringbone, moiré),
  physics (wave interference, pendulum, caustics, gravity lens),
  minimal (single shapes, gradients, dot grids),
  nature (ink, watercolor, smoke, sand garden, coral, nebula),
  color theory (RGB separation, CMY overlap, hue wheel),
  domain warping (acid trip, lava rift, storm eye),
  particles (fireflies, snow, ember, bubbles)
- Each shader has style_metadata (chaos_level, color_temperature, motion_type)
- Distributed creation times over 30 days for feed ranking variety
- Random initial scores for algorithm testing
- All authored by 'fractafrag' system account, is_system=true
- system_label='fractafrag-curated' for clear provenance

Schema:
- shader_versions table with (shader_id, version_number) unique constraint
- HNSW indexes on version lookup
- System account indexes
- Status-aware feed indexes
2026-03-24 22:00:10 -05:00

243 lines
11 KiB
Python

"""Fractafrag — SQLAlchemy ORM Models."""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, Integer, Float, SmallInteger,
ForeignKey, DateTime, UniqueConstraint, Index, CheckConstraint,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from pgvector.sqlalchemy import Vector
from sqlalchemy.orm import relationship
from app.database import Base
# System account UUID — the "fractafrag" platform user
SYSTEM_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String, unique=True, nullable=False, index=True)
email = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False)
role = Column(String, nullable=False, default="user")
trust_tier = Column(String, nullable=False, default="standard")
is_system = Column(Boolean, nullable=False, default=False)
stripe_customer_id = Column(String, nullable=True)
subscription_tier = Column(String, default="free")
ai_credits_remaining = Column(Integer, default=0)
taste_vector = Column(Vector(512), nullable=True)
is_verified_creator = Column(Boolean, default=False)
verified_creator_at = Column(DateTime(timezone=True), nullable=True)
stripe_connect_account_id = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
last_active_at = Column(DateTime(timezone=True), nullable=True)
shaders = relationship("Shader", back_populates="author")
votes = relationship("Vote", back_populates="user")
api_keys = relationship("ApiKey", back_populates="user")
class Shader(Base):
__tablename__ = "shaders"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
author_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
glsl_code = Column(Text, nullable=False)
status = Column(String, nullable=False, default="published") # draft, published, archived
is_public = Column(Boolean, default=True)
is_ai_generated = Column(Boolean, default=False)
is_system = Column(Boolean, default=False)
ai_provider = Column(String, nullable=True)
system_label = Column(String, nullable=True)
thumbnail_url = Column(String, nullable=True)
preview_url = Column(String, nullable=True)
render_status = Column(String, default="pending")
style_vector = Column(Vector(512), nullable=True)
style_metadata = Column(JSONB, nullable=True)
tags = Column(ARRAY(String), default=list)
shader_type = Column(String, default="2d")
forked_from = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="SET NULL"), nullable=True)
current_version = Column(Integer, nullable=False, default=1)
view_count = Column(Integer, default=0)
score = Column(Float, default=0.0)
access_tier = Column(String, default="open")
source_unlock_price_cents = Column(Integer, nullable=True)
commercial_license_price_cents = Column(Integer, nullable=True)
verified_creator_shader = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
author = relationship("User", back_populates="shaders")
votes = relationship("Vote", back_populates="shader")
versions = relationship("ShaderVersion", back_populates="shader", order_by="ShaderVersion.version_number.desc()")
class ShaderVersion(Base):
__tablename__ = "shader_versions"
__table_args__ = (UniqueConstraint("shader_id", "version_number"),)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
version_number = Column(Integer, nullable=False)
glsl_code = Column(Text, nullable=False)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
tags = Column(ARRAY(String), default=list)
style_metadata = Column(JSONB, nullable=True)
change_note = Column(Text, nullable=True)
thumbnail_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
shader = relationship("Shader", back_populates="versions")
class Vote(Base):
__tablename__ = "votes"
__table_args__ = (UniqueConstraint("user_id", "shader_id"),)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
value = Column(SmallInteger, nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
user = relationship("User", back_populates="votes")
shader = relationship("Shader", back_populates="votes")
class EngagementEvent(Base):
__tablename__ = "engagement_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
session_id = Column(String, nullable=True)
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
event_type = Column(String, nullable=False)
dwell_secs = Column(Float, nullable=True)
event_metadata = Column("metadata", JSONB, nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class Desire(Base):
__tablename__ = "desires"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
author_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
prompt_text = Column(Text, nullable=False)
prompt_embedding = Column(Vector(512), nullable=True)
style_hints = Column(JSONB, nullable=True)
tip_amount_cents = Column(Integer, default=0)
status = Column(String, default="open")
heat_score = Column(Float, default=1.0)
fulfilled_by_shader = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="SET NULL"), nullable=True)
fulfilled_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class DesireCluster(Base):
__tablename__ = "desire_clusters"
cluster_id = Column(UUID(as_uuid=True), primary_key=True)
desire_id = Column(UUID(as_uuid=True), ForeignKey("desires.id", ondelete="CASCADE"), primary_key=True)
similarity = Column(Float)
class BountyTip(Base):
__tablename__ = "bounty_tips"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
desire_id = Column(UUID(as_uuid=True), ForeignKey("desires.id", ondelete="CASCADE"), nullable=False)
tipper_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
amount_cents = Column(Integer, nullable=False)
stripe_payment_intent_id = Column(String, nullable=True)
status = Column(String, default="held")
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class CreatorPayout(Base):
__tablename__ = "creator_payouts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
creator_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
desire_id = Column(UUID(as_uuid=True), ForeignKey("desires.id", ondelete="SET NULL"), nullable=True)
gross_amount_cents = Column(Integer)
platform_fee_cents = Column(Integer)
net_amount_cents = Column(Integer)
stripe_transfer_id = Column(String, nullable=True)
status = Column(String, default="pending")
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class ApiKey(Base):
__tablename__ = "api_keys"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
key_hash = Column(String, unique=True, nullable=False)
key_prefix = Column(String, nullable=False)
name = Column(String, nullable=True)
trust_tier = Column(String, default="probation")
submissions_approved = Column(Integer, default=0)
rate_limit_per_hour = Column(Integer, default=10)
last_used_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
revoked_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User", back_populates="api_keys")
class GenerationLog(Base):
__tablename__ = "generation_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="SET NULL"), nullable=True)
provider = Column(String, nullable=False)
prompt_text = Column(Text, nullable=True)
tokens_used = Column(Integer, nullable=True)
cost_cents = Column(Integer, nullable=True)
success = Column(Boolean, nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class Comment(Base):
__tablename__ = "comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
author_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
body = Column(Text, nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey("comments.id", ondelete="CASCADE"), nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class SourceUnlock(Base):
__tablename__ = "source_unlocks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
buyer_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
license_type = Column(String, nullable=False)
amount_cents = Column(Integer, nullable=False)
platform_fee_cents = Column(Integer, nullable=False)
stripe_payment_intent_id = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
class CreatorEngagementSnapshot(Base):
__tablename__ = "creator_engagement_snapshots"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
creator_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
month = Column(DateTime, nullable=False)
total_score = Column(Float, nullable=False)
pool_share = Column(Float, nullable=True)
payout_cents = Column(Integer, nullable=True)
paid_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)