Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.crypto import generate_secret_token_urlsafe, hash_opaque_token
|
||||
from app.core.exceptions import DomainError
|
||||
from app.modules.sync import repository as repo
|
||||
from app.modules.sync.schemas import (
|
||||
AckChangesRequest,
|
||||
AckChangesResponse,
|
||||
ChangeEventIn,
|
||||
ChangeEventOut,
|
||||
ConflictDetail,
|
||||
ConflictSummary,
|
||||
HeartbeatRequest,
|
||||
HeartbeatResponse,
|
||||
PairConfirmRequest,
|
||||
PairConfirmResponse,
|
||||
PairStartRequest,
|
||||
PairStartResponse,
|
||||
PullChangesRequest,
|
||||
PullChangesResponse,
|
||||
PushChangesRequest,
|
||||
PushChangesResponse,
|
||||
ResolveConflictRequest,
|
||||
SyncCapabilitiesResponse,
|
||||
)
|
||||
from app.modules.sync.engine import SyncEngine
|
||||
from app.modules.users.models import User
|
||||
|
||||
|
||||
class SyncServiceError(DomainError):
|
||||
pass
|
||||
|
||||
|
||||
def get_capabilities() -> SyncCapabilitiesResponse:
|
||||
return SyncCapabilitiesResponse()
|
||||
|
||||
|
||||
def start_pairing(user: User, body: PairStartRequest) -> PairStartResponse:
|
||||
member = repo.get_member(user.id, body.enterprise_id)
|
||||
if not member or member.role != "admin":
|
||||
if not user.is_superuser:
|
||||
raise SyncServiceError("ENTERPRISE_ADMIN_ONLY")
|
||||
enterprise = repo.get_enterprise_by_id(body.enterprise_id)
|
||||
if not enterprise:
|
||||
raise SyncServiceError("ENTERPRISE_NOT_FOUND")
|
||||
code = f"{secrets.randbelow(900000) + 100000:06d}"
|
||||
session = repo.create_pairing_session(body.enterprise_id, code, hash_opaque_token(code))
|
||||
return PairStartResponse(session_id=session.id, code=code, expires_at=session.expires_at)
|
||||
|
||||
|
||||
def confirm_pairing(body: PairConfirmRequest) -> PairConfirmResponse:
|
||||
code_hash = hash_opaque_token(body.code.strip())
|
||||
session = repo.get_pairing_session_by_code_hash(code_hash)
|
||||
if not session:
|
||||
raise SyncServiceError("INVALID_PAIRING_CODE")
|
||||
if session.expires_at.replace(tzinfo=UTC) < datetime.now(UTC):
|
||||
raise SyncServiceError("PAIRING_CODE_EXPIRED")
|
||||
hub = repo.create_farm_hub(
|
||||
session.enterprise_id,
|
||||
body.hub_name or f"Hub {body.hub_site_id[:8]}",
|
||||
body.hub_site_id,
|
||||
body.hub_url,
|
||||
)
|
||||
api_key = generate_secret_token_urlsafe(48)
|
||||
repo.create_hub_credential(hub.id, api_key)
|
||||
repo.confirm_pairing_session(session.id, hub.id)
|
||||
return PairConfirmResponse(
|
||||
farm_hub_id=hub.id,
|
||||
hub_site_id=hub.hub_site_id,
|
||||
api_key=api_key,
|
||||
enterprise_id=session.enterprise_id,
|
||||
)
|
||||
|
||||
|
||||
def hub_heartbeat(hub, body: HeartbeatRequest) -> HeartbeatResponse:
|
||||
repo.update_hub_heartbeat(hub.farm_hub_id, body.wesp_version)
|
||||
return HeartbeatResponse(server_time=datetime.now(UTC))
|
||||
|
||||
|
||||
def push_changes(hub, body: PushChangesRequest) -> PushChangesResponse:
|
||||
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_push(body.events, origin="hub")
|
||||
|
||||
|
||||
def pull_changes(hub, body: PullChangesRequest) -> PullChangesResponse:
|
||||
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_pull(hub.farm_hub_id, body.cursor, body.limit)
|
||||
|
||||
|
||||
def ack_changes(hub, body: AckChangesRequest) -> AckChangesResponse:
|
||||
return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_ack(hub.farm_hub_id, body)
|
||||
|
||||
|
||||
def list_conflicts_for_enterprise(enterprise_id: str) -> list[ConflictSummary]:
|
||||
rows = repo.list_conflicts(enterprise_id)
|
||||
return [
|
||||
ConflictSummary(
|
||||
id=row.id,
|
||||
table_name=row.table_name,
|
||||
record_id=row.record_id,
|
||||
farm_hub_id=row.farm_hub_id,
|
||||
status=row.status,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_conflict_detail(conflict_id: str, enterprise_id: str) -> ConflictDetail:
|
||||
row = repo.get_conflict(conflict_id)
|
||||
if not row or row.enterprise_id != enterprise_id:
|
||||
raise SyncServiceError("CONFLICT_NOT_FOUND")
|
||||
return ConflictDetail(
|
||||
id=row.id,
|
||||
table_name=row.table_name,
|
||||
record_id=row.record_id,
|
||||
farm_hub_id=row.farm_hub_id,
|
||||
status=row.status,
|
||||
created_at=row.created_at,
|
||||
orchestrator_snapshot=json.loads(row.orchestrator_snapshot_json or "{}"),
|
||||
hub_snapshot=json.loads(row.hub_snapshot_json or "{}"),
|
||||
held_event_ids=json.loads(row.held_event_ids_json or "[]"),
|
||||
)
|
||||
|
||||
|
||||
def resolve_conflict(conflict_id: str, enterprise_id: str, user_id: str, body: ResolveConflictRequest) -> None:
|
||||
SyncEngine(enterprise_id, "orchestrator").resolve_conflict(conflict_id, user_id, body.resolution)
|
||||
|
||||
|
||||
def get_sync_metrics(enterprise_id: str) -> dict:
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.models import FarmHub, SyncConflict, SyncCursor, SyncEventLog, SyncOutbox
|
||||
|
||||
with session_scope() as db:
|
||||
hubs = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id)))
|
||||
outbox_pending = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(SyncOutbox)
|
||||
.where(SyncOutbox.enterprise_id == enterprise_id, SyncOutbox.status == "pending")
|
||||
)
|
||||
conflicts_pending = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(SyncConflict)
|
||||
.where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == "pending")
|
||||
)
|
||||
max_seq = db.scalar(
|
||||
select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id)
|
||||
) or 0
|
||||
hub_metrics = []
|
||||
for h in hubs:
|
||||
cursor = db.scalar(
|
||||
select(SyncCursor).where(
|
||||
SyncCursor.farm_hub_id == h.id,
|
||||
SyncCursor.direction == "inbound",
|
||||
)
|
||||
)
|
||||
lag = max(0, int(max_seq) - int(cursor.last_acked_seq if cursor else 0))
|
||||
hub_metrics.append(
|
||||
{
|
||||
"farm_hub_id": h.id,
|
||||
"name": h.name,
|
||||
"hub_site_id": h.hub_site_id,
|
||||
"status": h.status,
|
||||
"last_seen": h.last_seen.isoformat() if h.last_seen else None,
|
||||
"sync_lag": lag,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"hubs": hub_metrics,
|
||||
"outbox_pending": int(outbox_pending or 0),
|
||||
"conflicts_pending": int(conflicts_pending or 0),
|
||||
"max_event_seq": int(max_seq),
|
||||
}
|
||||
Reference in New Issue
Block a user