Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 8 additions & 6 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
name: codeql
name: codeql-advanced (manual)

on:
push:
branches: ["main"]
pull_request:
schedule:
- cron: "0 2 * * 1"
# NOTE: This repository has GitHub Code Scanning "default setup" enabled and
# it is controlled by organization administrators. Running an advanced
# CodeQL workflow alongside default setup can fail with:
# "CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled".
#
# To avoid merge-gate noise, keep this workflow manual only.
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

permissions:
contents: read
Expand Down
26 changes: 26 additions & 0 deletions backend/alembic/versions/0002_auth_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,31 @@


def upgrade() -> None:
# Backfill: ensure any existing project_space.created_by_user_uuid values exist in user_account
# before adding FK constraints. Previous versions could have inserted random UUIDs.
op.execute(
"""
INSERT INTO user_account (user_account_uuid, oidc_subject, display_name, created_at)
SELECT DISTINCT
p.created_by_user_uuid,
'migrated:' || p.created_by_user_uuid::text,
'migrated-' || p.created_by_user_uuid::text,
now()
FROM project_space p
LEFT JOIN user_account u ON u.user_account_uuid = p.created_by_user_uuid
WHERE p.created_by_user_uuid IS NOT NULL AND u.user_account_uuid IS NULL
ON CONFLICT DO NOTHING;
"""
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Add FK constraints (MVP: best-effort for fresh DB)
op.create_foreign_key(
"fk_project_space__created_by_user",
"project_space",
"user_account",
["created_by_user_uuid"],
["user_account_uuid"],
postgresql_not_valid=True,
)
op.create_foreign_key(
"fk_project_member__project_space",
Expand Down Expand Up @@ -139,3 +157,11 @@ def downgrade() -> None:
op.drop_constraint(
"fk_project_space__created_by_user", "project_space", type_="foreignkey"
)

# Remove backfilled user_account rows created during upgrade.
op.execute(
"""
DELETE FROM user_account
WHERE oidc_subject LIKE 'migrated:%';
"""
)
28 changes: 28 additions & 0 deletions backend/alembic/versions/0003_validate_project_space_fk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""validate project_space created_by FK

Revision ID: 0003_validate_project_space_fk
Revises: 0002_auth_share
Create Date: 2026-02-01

"""

from __future__ import annotations

from alembic import op

revision = "0003_validate_project_space_fk"
down_revision = "0002_auth_share"
branch_labels = None
depends_on = None


def upgrade() -> None:
# Validate the FK created as NOT VALID in 0002.
op.execute(
"ALTER TABLE project_space VALIDATE CONSTRAINT fk_project_space__created_by_user;"
)


def downgrade() -> None:
# No-op: validation does not require rollback.
pass
41 changes: 34 additions & 7 deletions backend/app/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import CurrentUser, get_current_user
Expand Down Expand Up @@ -145,17 +146,43 @@ async def add_project_member(
session.add(u)
await session.flush()

m = ProjectMember(
project_space_uuid=project_space_uuid,
user_account_uuid=u.user_account_uuid,
project_role=body.project_role,
created_at=dt.datetime.now(dt.timezone.utc),
# Idempotent invite: if already a member, update role instead of raising 500.
row3 = await session.execute(
select(ProjectMember.project_role).where(
ProjectMember.project_space_uuid == project_space_uuid,
ProjectMember.user_account_uuid == u.user_account_uuid,
)
)
session.add(m)
existing_role = row3.scalar_one_or_none()
if existing_role == "owner":
# Avoid leaving project without an owner via this endpoint.
raise HTTPException(
status_code=400, detail="cannot change owner role via invite endpoint"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Race-safe upsert on composite PK.
stmt = (
insert(ProjectMember)
.values(
project_space_uuid=project_space_uuid,
user_account_uuid=u.user_account_uuid,
project_role=body.project_role,
created_at=dt.datetime.now(dt.timezone.utc),
)
.on_conflict_do_update(
index_elements=[
ProjectMember.project_space_uuid,
ProjectMember.user_account_uuid,
],
set_={"project_role": body.project_role},
)
.returning(ProjectMember.project_role)
)
new_role = (await session.execute(stmt)).scalar_one()
await session.commit()

return ProjectMemberOut(
user_account_uuid=u.user_account_uuid,
member_subject=u.oidc_subject,
project_role=m.project_role,
project_role=str(new_role),
)
25 changes: 17 additions & 8 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
Expand All @@ -16,7 +18,21 @@
from app.settings import settings


app = FastAPI(title="pg-erd-cloud backend")
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
handlers = {"snapshot": handle_snapshot_job}
task = asyncio.create_task(run_worker_forever(SessionLocal, handlers))
try:
yield
finally:
task.cancel()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
await task
except asyncio.CancelledError:
pass

Comment thread
coderabbitai[bot] marked this conversation as resolved.

app = FastAPI(title="pg-erd-cloud backend", lifespan=lifespan)

app.add_middleware(
CORSMiddleware,
Expand All @@ -37,10 +53,3 @@ async def healthz() -> dict:
app.include_router(snapshots_router)
app.include_router(me_router)
app.include_router(share_router)


@app.on_event("startup")
async def start_worker() -> None:
handlers = {"snapshot": handle_snapshot_job}
# Background worker loop. For production, run as separate process.
asyncio.create_task(run_worker_forever(SessionLocal, handlers))
24 changes: 16 additions & 8 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@ class ProjectMember(Base):

project_space_uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("project_space.project_space_uuid"),
ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"),
primary_key=True,
)
user_account_uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("user_account.user_account_uuid"),
ForeignKey("user_account.user_account_uuid", ondelete="CASCADE"),
primary_key=True,
)
project_role: Mapped[str] = mapped_column(Text())
Expand All @@ -74,7 +74,9 @@ class DbConnection(Base):
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_space_uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("project_space.project_space_uuid"), index=True
UUID(as_uuid=True),
ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"),
index=True,
)
conn_name: Mapped[str] = mapped_column(Text())
dsn_ciphertext: Mapped[bytes] = mapped_column(LargeBinary())
Expand All @@ -94,10 +96,13 @@ class SchemaSnapshot(Base):
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_space_uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("project_space.project_space_uuid"), index=True
UUID(as_uuid=True),
ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"),
index=True,
)
db_connection_uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("db_connection.db_connection_uuid")
UUID(as_uuid=True),
ForeignKey("db_connection.db_connection_uuid", ondelete="CASCADE"),
)
status: Mapped[str] = mapped_column(Text())
schema_filter: Mapped[str | None] = mapped_column(Text(), nullable=True)
Expand All @@ -118,7 +123,7 @@ class SchemaSnapshotData(Base):

schema_snapshot_uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("schema_snapshot.schema_snapshot_uuid"),
ForeignKey("schema_snapshot.schema_snapshot_uuid", ondelete="CASCADE"),
primary_key=True,
)
snapshot_json: Mapped[dict] = mapped_column(JSONB())
Expand Down Expand Up @@ -161,10 +166,13 @@ class ShareLink(Base):
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
project_space_uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("project_space.project_space_uuid"), index=True
UUID(as_uuid=True),
ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"),
index=True,
)
created_by_user_uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("user_account.user_account_uuid")
UUID(as_uuid=True),
ForeignKey("user_account.user_account_uuid", ondelete="CASCADE"),
)
permission_kind: Mapped[str] = mapped_column(Text()) # viewer/editor (MVP: viewer)
expires_at: Mapped[dt.datetime | None] = mapped_column(
Expand Down
4 changes: 3 additions & 1 deletion backend/app/schemas.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import uuid
from typing import Literal

from pydantic import BaseModel, Field

Expand All @@ -18,7 +19,8 @@ class ProjectMemberAddIn(BaseModel):
member_subject: str = Field(
min_length=1, description="OIDC sub, or dev:<name> in dev mode"
)
project_role: str = Field(default="viewer")
# MVP: restrict to non-owner roles. Owner is assigned at project creation.
project_role: Literal["viewer", "editor"] = Field(default="viewer")


class ProjectMemberOut(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ dependencies = [
"alembic>=1.13.0",
"cryptography>=42.0.0",
"httpx>=0.27.0",
"python-jose[cryptography]>=3.3.0",
"python-jose[cryptography]>=3.4.0",
]

[project.optional-dependencies]
Expand Down
26 changes: 23 additions & 3 deletions frontend/src/erd/TableNode.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NodeProps } from '@xyflow/react'
import { memo } from 'react'
import type { Node, NodeProps } from '@xyflow/react'

type Column = {
column_name: string
Expand All @@ -7,8 +8,16 @@ type Column = {
is_pk?: boolean
}

export default function TableNode(props: NodeProps) {
const data = props.data as { title: string; columns: Column[]; badges?: { pk?: boolean; fk?: boolean } }
type TableNodeData = {
title: string
columns: Column[]
badges?: { pk?: boolean; fk?: boolean }
}

type TableNodeNode = Node<TableNodeData, 'tableNode'>

function TableNode(props: NodeProps<TableNodeNode>) {
const { data } = props
return (
<div className="tableNode">
<div className="tableNode__title">
Expand All @@ -32,3 +41,14 @@ export default function TableNode(props: NodeProps) {
</div>
)
}

export default memo(TableNode, (prev, next) => {
// React Flow typically provides new node objects when data changes.
// This comparator is a conservative safeguard for the most relevant fields.
return (
prev.data.title === next.data.title &&
prev.data.columns === next.data.columns &&
prev.data.badges?.pk === next.data.badges?.pk &&
prev.data.badges?.fk === next.data.badges?.fk
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
Loading