Skip to content

Commit a4b444f

Browse files
authored
Paform (#7)
1 parent 102fc53 commit a4b444f

File tree

17 files changed

+645
-41
lines changed

17 files changed

+645
-41
lines changed

.github/workflows/assets.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: assets
2+
on:
3+
pull_request: {}
4+
5+
jobs:
6+
validate-assets:
7+
runs-on: ubuntu-latest
8+
timeout-minutes: 20
9+
steps:
10+
- uses: actions/checkout@v4
11+
12+
- name: Setup Node 20
13+
uses: actions/setup-node@v4
14+
with:
15+
node-version: '20'
16+
cache: 'npm'
17+
cache-dependency-path: paform/frontend/package-lock.json
18+
19+
- name: Install frontend deps
20+
run: |
21+
cd paform/frontend
22+
npm ci
23+
24+
- name: Generate reference GLBs
25+
run: |
26+
cd paform
27+
python3 scripts/generate_reference_glbs.py
28+
29+
- name: Pack GLBs (LOD0/LOD1) with KTX2
30+
run: |
31+
cd paform
32+
bash scripts/pack_models.sh
33+
34+
- name: Validate GLBs (fail on warnings)
35+
run: |
36+
cd paform
37+
python3 scripts/glb_validate.py frontend/public/models/*.glb --fail-on-warning
38+
39+
- name: Generate manifest.json
40+
run: |
41+
cd paform
42+
python3 scripts/gen_glb_manifest.py > frontend/public/models/manifest.json
43+
44+
- name: Upload manifest
45+
if: always()
46+
uses: actions/upload-artifact@v4
47+
with:
48+
name: assets-artifacts
49+
path: paform/frontend/public/models/manifest.json
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from alembic import op
2+
import sqlalchemy as sa
3+
4+
# revision identifiers, used by Alembic.
5+
revision = "cfe1d8e4e001_add_sync_events"
6+
down_revision = "0001"
7+
branch_labels = None
8+
depends_on = None
9+
10+
11+
def upgrade() -> None:
12+
op.create_table(
13+
"sync_events",
14+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
15+
sa.Column("event_id", sa.String(length=255), nullable=True),
16+
sa.Column("source", sa.String(length=100), nullable=False),
17+
sa.Column("body_sha256", sa.String(length=64), nullable=False),
18+
sa.Column(
19+
"received_at",
20+
sa.TIMESTAMP(timezone=True),
21+
server_default=sa.text("CURRENT_TIMESTAMP"),
22+
nullable=False,
23+
),
24+
sa.UniqueConstraint("source", "event_id", name="uq_sync_events_src_event"),
25+
sa.UniqueConstraint("source", "body_sha256", name="uq_sync_events_src_body"),
26+
sa.UniqueConstraint("body_sha256", name="uq_sync_events_body"),
27+
)
28+
29+
30+
def downgrade() -> None:
31+
op.drop_table("sync_events")

backend/api/db.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
"""Database configuration and session management using SQLAlchemy 2.x."""
2-
32
from __future__ import annotations
43

54
import os
@@ -21,7 +20,6 @@ def _normalize_postgres_url(url: str) -> str:
2120
Accepts legacy prefixes and ensures the SQLAlchemy URL includes the
2221
``+psycopg`` dialect when using PostgreSQL.
2322
"""
24-
2523
if url.startswith("postgres://"):
2624
return url.replace("postgres://", "postgresql+psycopg://", 1)
2725
if url.startswith("postgresql://") and "+psycopg" not in url:
@@ -31,17 +29,14 @@ def _normalize_postgres_url(url: str) -> str:
3129

3230
def get_engine_url() -> str:
3331
"""Resolve the database URL from env or settings with sane defaults."""
34-
3532
settings = Settings()
3633
url = os.getenv("DATABASE_URL", settings.database_url)
3734
if url.startswith("sqlite"):
38-
# SQLite works as-is
3935
return url
4036
return _normalize_postgres_url(url)
4137

4238

4339
ENGINE_URL = get_engine_url()
44-
4540
engine = create_engine(ENGINE_URL, pool_pre_ping=True, future=True)
4641

4742
SessionLocal = sessionmaker(
@@ -51,14 +46,14 @@ def get_engine_url() -> str:
5146
future=True,
5247
)
5348

49+
# Ensure models are registered on Base.metadata for create_all/drop_all
50+
import api.models # noqa: E402,F401
51+
5452

5553
def get_db() -> Generator:
5654
"""FastAPI dependency to provide a session per request."""
57-
5855
db = SessionLocal()
5956
try:
6057
yield db
6158
finally:
62-
db.close()
63-
64-
59+
db.close()

backend/api/main.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import logging
44
from typing import Dict
55

6-
from fastapi import FastAPI
6+
from fastapi import FastAPI, Response, Request
7+
from fastapi.exceptions import RequestValidationError
8+
from fastapi.responses import JSONResponse
79
from starlette.middleware.cors import CORSMiddleware
810

911
from api.config import Settings
@@ -13,6 +15,7 @@
1315
from api.routes_quote import router as quote_router
1416
from api.routes_cnc import router as cnc_router
1517
from api.routes_sync import router as sync_router
18+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
1619

1720
# Configure logging
1821
logging.basicConfig(
@@ -30,6 +33,15 @@
3033
version="0.1.0",
3134
)
3235

36+
from api.db import Base, engine
37+
from api.config import Settings as _Settings
38+
39+
# Ensure tables exist in SQLite (dev/test); migrations still handle Postgres
40+
if _Settings().database_url.startswith("sqlite"):
41+
@app.on_event("startup")
42+
async def _ensure_sqlite_tables() -> None:
43+
Base.metadata.create_all(bind=engine)
44+
3345
# Add CORS middleware
3446
app.add_middleware(
3547
CORSMiddleware,
@@ -48,6 +60,22 @@
4860
app.include_router(sync_router)
4961

5062

63+
@app.exception_handler(RequestValidationError)
64+
async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
65+
# Unified error envelope for malformed JSON / validation errors
66+
return JSONResponse(
67+
status_code=422,
68+
content={
69+
"ok": False,
70+
"error": {
71+
"code": "BAD_REQUEST",
72+
"message": "invalid request",
73+
"details": exc.errors(),
74+
},
75+
},
76+
)
77+
78+
5179
@app.get("/")
5280
async def root() -> Dict[str, str]:
5381
"""Root endpoint of the API.
@@ -62,8 +90,7 @@ async def root() -> Dict[str, str]:
6290

6391
@app.get("/healthcheck")
6492
async def healthcheck() -> Dict[str, str]:
65-
"""Health check endpoint.
66-
93+
"""
6794
This endpoint can be used to verify that the API is running and responsive.
6895
6996
Returns
@@ -72,3 +99,9 @@ async def healthcheck() -> Dict[str, str]:
7299
A dictionary indicating the health status of the API.
73100
"""
74101
return {"status": "healthy"}
102+
103+
104+
@app.get("/metrics")
105+
async def metrics() -> Response:
106+
# Expose Prometheus metrics including default process/python collectors
107+
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)

backend/api/metrics.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from __future__ import annotations
2+
from prometheus_client import Counter
3+
4+
# Hygraph sync counters
5+
sync_success_total = Counter(
6+
"sync_success_total",
7+
"Successful Hygraph sync operations",
8+
labelnames=("type",),
9+
)
10+
11+
sync_failure_total = Counter(
12+
"sync_failure_total",
13+
"Failed Hygraph sync operations",
14+
labelnames=("type",),
15+
)
16+
17+
sync_records_upserted_total = Counter(
18+
"sync_records_upserted_total",
19+
"Records upserted during Hygraph sync",
20+
labelnames=("type",),
21+
)

backend/api/models.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
H3: Hardened data models for materials and modules supporting assembly logic,
44
placement constraints, connection points, and external IDs for BIM/Revit.
55
"""
6-
76
from __future__ import annotations
87

98
import uuid
@@ -18,13 +17,13 @@
1817
Index,
1918
String,
2019
func,
20+
UniqueConstraint,
2121
)
2222
from sqlalchemy.dialects.postgresql import JSONB
2323
from sqlalchemy.orm import Mapped, mapped_column, relationship
2424

2525
from api.db import Base
2626

27-
2827
# Cross-dialect JSON -> JSONB for PostgreSQL, JSON otherwise
2928
JSON_COMPAT = JSON().with_variant(JSONB, "postgresql")
3029

@@ -115,3 +114,20 @@ class Module(Base):
115114
Index("ix_modules_material_name", Module.material_id, Module.name)
116115

117116

117+
# Sync events for webhook deduplication
118+
class SyncEvent(Base):
119+
__tablename__ = "sync_events"
120+
121+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
122+
event_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
123+
source: Mapped[str] = mapped_column(String(100), nullable=False)
124+
body_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
125+
received_at: Mapped[datetime] = mapped_column(
126+
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
127+
)
128+
129+
__table_args__ = (
130+
UniqueConstraint("source", "event_id", name="uq_sync_events_src_event"),
131+
UniqueConstraint("source", "body_sha256", name="uq_sync_events_src_body"),
132+
UniqueConstraint("body_sha256", name="uq_sync_events_body"),
133+
)

0 commit comments

Comments
 (0)