20 KiB
convex/ — Backend-Schicht
Convex ist das vollständige Backend von LemonSpace: Datenbank, Realtime-Subscriptions, File Storage und Background-Job-Scheduler in einem. Kein separates API-Layer notwendig.
Dateien im Überblick
| Datei | Zweck |
|---|---|
schema.ts |
Einzige Wahrheitsquelle für alle Tabellen und Typen |
node_type_validator.ts |
Node-Typen Validator (Phase 1, Phase 2, Phase 3, Adjustment Presets) |
ai.ts |
KI-Bild- und KI-Video-Generierungs-Pipeline |
credits.ts |
Credit-System: Balance, Reservation+Commit, Tier-Config |
nodes.ts |
CRUD für Canvas-Nodes |
edges.ts |
CRUD für Canvas-Edges |
canvases.ts |
CRUD für Canvases |
openrouter.ts |
OpenRouter-HTTP-Client + Modell-Config (Backend) |
auth.ts |
Better Auth Integration |
helpers.ts |
requireAuth() + optionalAuth() für Auth-Checks |
batch_validation_utils.ts |
Validierung von Batch-Node-Operationen |
canvas-connection-policy.ts |
Verbindungspolitiken zwischen Nodes (Validierung) |
polar.ts |
Polar.sh Webhook-Handler (Subscriptions) |
pexels.ts |
Pexels Stock-Bilder API |
freepik.ts |
Freepik Asset-Browser API + Video-Generierungs-Client |
agents.ts |
Agent-Orchestrierung: Analyze/Execute-Flow, Clarifications, strukturierte Outputs, Scheduler/Credits-Integration |
ai_utils.ts |
Gemeinsame Helpers für AI-Pipeline (z. B. assertNodeBelongsToCanvasOrThrow) |
storage.ts |
Convex File Storage Helpers + gebündelte Canvas-URL-Auflösung |
export.ts |
Canvas-Export-Logik |
http.ts |
HTTP-Endpunkte (Webhooks) |
dashboard.ts |
Gebündelte Dashboard-Snapshot-Query (Balance, Subscription, Usage, Transactions, Canvases in einem Call) |
canvasGraph.ts |
Canvas Graph Query — Performance-optimierte Query für Nodes+Edges in einem Call |
ai_errors.ts |
Error-Kategorisierung und User-facing Fehlermeldungen (aus ai.ts extrahiert) |
ai_node_data.ts |
Node-Data-Helpers (z. B. getNodeDataRecord) |
ai_retry.ts |
Retry-Logik für AI-Generierung (generateImageWithAutoRetry, aus ai.ts extrahiert) |
Schema (schema.ts)
Alle Node-Typen werden über Validators definiert: phase1NodeTypeValidator, nodeTypeValidator (Phase 1+), adjustmentNodeTypeValidator, und adjustmentPresetNodeTypeValidator.
Phase-Struktur
- Phase 1 Nodes: Aktiv implementierte Nodes (
PHASE1_CANVAS_NODE_TYPES) - Phase 2/3 Nodes: Vordeklariert, aber
implemented: false— UI filtert nach Phase - Adjustment Presets: Spezielle Presets für Curves, Color Adjust, Light Adjust, Detail Adjust
Node Data Shapes
| Node-Typ | Felder | Bemerkung |
|---|---|---|
image |
storageId, url, mimeType, width, height |
Bild-Upload oder URL |
text |
content |
Markdown-Text |
prompt |
content, model, modelTier |
KI-Generierungsanweisung |
ai-image |
storageId, prompt, model, modelTier, parameters, generationTimeMs, creditCost |
Generiertes KI-Bild |
text-node |
content |
Generierter KI-Text |
video-prompt |
content, modelId, durationSeconds |
KI-Video-Steuer-Node (Eingabe) |
ai-video |
storageId, prompt, model, modelLabel, durationSeconds, creditCost, generatedAt, taskId (transient) |
Generiertes KI-Video (System-Output) |
compare |
leftNodeId, rightNodeId, sliderPosition |
Vergleichs-Node |
frame |
label, exportWidth, exportHeight, backgroundColor |
Artboard |
group |
label, collapsed |
Container-Node |
note |
content, color |
Anmerkung |
curves |
Presets (Kurven) | Nicht im UI als Node-Typ, sondern als Presets |
color-adjust |
Presets (Farbkorrektur) | |
light-adjust |
Presets (Helligkeit) | |
detail-adjust |
Presets (Details) |
Tabellen
canvases — Canvas pro User. Index: by_owner, by_owner_updated.
nodes — Alle Nodes eines Canvas. Felder: type, positionX/Y, width, height, status (idle|analyzing|clarifying|executing|done|error), statusMessage, retryCount, data (v.any()), parentId, zIndex. Index: by_canvas, by_canvas_type, by_parent.
dataistv.any()— Typ-Safety läuft über dentype-Discriminator + Zod im Frontend. Die Node-Data-Shapes sind inschema.tsdokumentiert.
edges — Verbindungen zwischen Nodes. Index: by_canvas, by_source, by_target.
mutationRequests — Idempotenz-Layer für Client-Replay (clientRequestId) bei offline/Retry-Sync. Felder: userId, mutation, clientRequestId, optionale Ziel-IDs (canvasId, nodeId, edgeId). Index: by_user_mutation_request.
adjustmentPresets — Benutzerspezifische Presets für Adjustment-Nodes. Index: by_userId, by_userId_nodeType.
creditBalances — Pro User: balance, reserved, monthlyAllocation. available = balance - reserved (computed, nicht gespeichert).
creditTransactions — Jede Credit-Bewegung. Types: subscription | topup | usage | reservation | refund. Status: committed | reserved | released | failed. Optionale Felder: provider (openrouter | freepik), videoMeta (model, durationSeconds, hasAudio).
subscriptions — Aktive Subscription. Tier: free | starter | pro | max | business.
dailyUsage — Täglicher Zähler pro User für Abuse-Prevention. Key: userId + date (ISO).
Agent-Orchestrierung (agents.ts)
agents.ts orchestriert den Lauf von Agent-Nodes in zwei Stufen:
- Analyze: Brief + Kontext auswerten, Clarification-Fragen und Execution-Plan erzeugen.
- Execute: Pro Plan-Step strukturierte Deliverables erzeugen und in
agent-output-Nodes persistieren.
Architekturgrenzen
- Scheduling, Status-Mutationen und Credit-Flow bleiben in
agents.ts. - Prompt-Aufbau liegt in
lib/agent-prompting.ts(summarizeIncomingContext,buildAnalyzeMessages,buildExecuteMessages). - Strukturvertraege und Normalisierung kommen aus
lib/agent-run-contract.ts. - Agent-Metadaten, Regeln und Blueprints kommen aus
lib/agent-definitions.ts. - Prompt-Segmente kommen aus
lib/generated/agent-doc-segments.ts(generiert durchscripts/compile-agent-docs.ts).
Wichtig: agents.ts liest keine Raw-Markdown-Dateien zur Laufzeit.
Strukturierte Output-Persistenz
Pro Step wird ein strukturierter Output gespeichert mit:
title,channel,artifactType,previewTextsections[](id,label,content)metadata(Record<string, string | string[]>)qualityChecks[]bodyals Legacy-Fallback
AI-Bild-Pipeline (ai.ts)
generateImage (action, public)
→ checkAbuseLimits (internalMutation)
→ reserve (mutation, wenn INTERNAL_CREDITS_ENABLED=true)
→ markNodeExecuting (internalMutation)
→ scheduler.runAfter(0, processImageGeneration) ← Background-Job
processImageGeneration (internalAction)
→ generateAndStoreImage (internalAction)
→ generateImageWithAutoRetry (lokal, max 2 Retries)
→ generateImageViaOpenRouter
→ ctx.storage.store(blob)
→ finalizeImageSuccess (internalMutation)
→ commitInternal (credits)
[Fehler] → releaseInternal (credits)
→ finalizeImageFailure (internalMutation)
[finally] → decrementConcurrency
Wichtig: generateImage gibt { queued: true } zurück, sobald der Background-Job geplant ist. Der Node wechselt sofort auf status: "executing". Der eigentliche API-Call läuft asynchron.
Retry-Logik: Max. 2 Retries (MAX_IMAGE_RETRIES = 2). Backoff: min(1500ms, 400ms * retryCount). Retryable: provider (5xx, 408, 429), timeout, transient. Nicht retryable: credits, policy, unbekannte 4xx.
env-Flags:
INTERNAL_CREDITS_ENABLED=true— Aktiviert Reservation+Commit. Beifalsewird nur Abuse-Prevention geprüft.OPENROUTER_API_KEY— Pflicht.
KI-Video-Pipeline (ai.ts)
Analog zur Bild-Pipeline, aber mit Freepik als Provider und asynchronem Polling statt direktem API-Call.
generateVideo (action, public)
→ checkAbuseLimits (internalMutation)
→ reserve (mutation, provider: "freepik", videoMeta: {...})
→ markNodeExecuting (internalMutation)
→ scheduler.runAfter(0, processVideoGeneration) ← Background-Job
processVideoGeneration (internalAction)
→ createVideoTask (freepik.ts) ← POST an Freepik API
→ setVideoTaskInfo (internalMutation) ← taskId am Node speichern
→ scheduler.runAfter(5s, pollVideoTask) ← Polling starten
pollVideoTask (internalAction, max 30 Versuche / 10 Min)
→ getVideoTaskStatus (freepik.ts) ← GET an modellspezifischen Endpunkt
[COMPLETED] → downloadVideoAsBlob → ctx.storage.store(blob)
→ finalizeVideoSuccess (internalMutation)
→ commitInternal (credits)
[FAILED] → releaseInternal (credits)
→ finalizeVideoFailure (internalMutation)
→ decrementConcurrency
[CREATED|IN_PROGRESS] → scheduler.runAfter(delay, pollVideoTask, attempt+1)
[retryable Fehler] → markVideoPollingRetry → scheduler.runAfter(delay, pollVideoTask, attempt+1)
[nicht-retryable] → releaseInternal → finalizeVideoFailure → decrementConcurrency
[Timeout/Max-Versuche] → releaseInternal → finalizeVideoFailure → decrementConcurrency
Wichtig: generateVideo gibt { queued: true, outputNodeId } zurück. Der Node wechselt sofort auf status: "executing". Freepik erstellt einen Async-Task, der via Polling überwacht wird.
Polling-Strategie: Exponential Backoff in 3 Stufen:
- Versuch 1–5: 5s Wartezeit
- Versuch 6–15: 10s Wartezeit
- Versuch 16–30: 20s Wartezeit
- Max. 30 Versuche oder 10 Minuten Gesamt-Timeout →
FAILED
Modellspezifische Endpunkte: Jedes Video-Modell hat einen eigenen Status-Endpunkt (statusEndpointPath in lib/ai-video-models.ts). Freepik hat keinen generischen /v1/ai/tasks/{taskId}-Endpunkt — der richtige Pfad ist z. B. /v1/ai/text-to-video/wan-2-5-t2v-720p/{task-id}.
Freepik Response-Formate: Endpunkte liefern unterschiedliche Formate:
task_idkann auf Root-Ebene oder unterdata.task_idstehengenerated-Array kann Strings oder{ url: string }-Objekte enthalten- Beide Formate werden in
freepik.tsdefensiv geparsed
Error-Klassen:
FreepikApiError— Eigene Error-Klasse mitsource: "freepik",status,code,retryable- HTTP 404 während Polling →
transient/retryable: true(Freepik eventual consistency) - HTTP 503 →
model_unavailable/retryable: true - HTTP 401 →
model_unavailable/retryable: false
Log-Volumen-Steuerung: Poll-Logs werden über lib/video-poll-logging.ts (shouldLogVideoPollAttempt, shouldLogVideoPollResult) auf Versuch 1, jeden 5. Versuch und Terminalzustände reduziert.
env-Flags:
FREEPIK_API_KEY— Freepik API-Key (Header:x-freepik-api-key)INTERNAL_CREDITS_ENABLED=true— Credit-Reservation aktiviert
Video-Modell-Registry: Siehe lib/ai-video-models.ts — 5 MVP-Modelle, Tier-basierte Freigabe, Credit-Kosten pro Clip-Länge (5s/10s).
Credit-System (credits.ts)
Tier-Konfiguration (TIER_CONFIG)
| Tier | Credits/Monat | Daily Cap | Concurrency | Premium-Modelle |
|---|---|---|---|---|
| free | 50 | 10 | 1 | nein |
| starter | 400 | 50 | 2 | ja |
| pro | 3.300 | 200 | 2 | ja |
| max | 6.700 | 500 | 2 | ja |
1 Credit = €0,01 (Euro-Cent-Einheit durchgängig in DB und UI).
Reservation+Commit-Flow
reserve()— Prüftavailable >= estimatedCost, Daily Cap, Concurrency. Erhöhtreserved+dailyUsage. GibttransactionIdzurück.- Job läuft...
commitInternal()— ZiehtactualCostvonbalanceab, gibtestimatedCostausreservedfrei. Schreibttype: "usage".- Bei Fehler:
releaseInternal()— GibtestimatedCostausreservedfrei.generationCountbleibt erhöht (Versuch zählt).
env-Flag: ALLOW_TEST_CREDIT_GRANT=true — Aktiviert grantTestCredits Mutation (nur Dev/Staging).
Freepik Video-Client (freepik.ts)
freepik.ts enthält sowohl die bestehende Asset-Suche als auch den Video-Generierungs-Client.
Video-Funktionen
createVideoTask({ endpoint, prompt, durationSeconds })— POST an Freepik-Endpunkt, liefert{ task_id }getVideoTaskStatus({ taskId, statusEndpointPath, attempt })— GET an modellspezifischen Status-Endpunkt, liefert{ status, generated?, error? }downloadVideoAsBlob(url)— Lädt Video von Freepik CDN als Blob (zeitbegrenzte Signed URL!)mapFreepikError(status, body)— Mappt HTTP-Status auf strukturierteFreepikMappedError-ObjekteFreepikApiError— Eigene Error-Klasse mitsource,status,code,retryable,body
Wichtig: Freepik CDN-URLs sind zeitbegrenzt. downloadVideoAsBlob muss sofort nach COMPLETED aufgerufen werden. Das Video wird dauerhaft in Convex Storage gesichert.
Auth (helpers.ts)
requireAuth(ctx) // → { userId: string }
optionalAuth(ctx) // → { userId: string } | null
Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genutzt, die User-Daten berühren.
Better-Auth Setup (auth.ts)
- Auth-Library läuft in Convex (
createAuth) und wird viaauthComponent.registerRoutes(http, createAuth)inhttp.tsregistriert. - Login-Modi:
emailAndPassword(mitrequireEmailVerification: true)magicLinkPlugin (better-auth/plugins)
- Magic-Link-Konfiguration:
disableSignUp: true(Magic Link nur für bestehende Accounts)expiresIn: 600(10 Minuten)- Versand über Resend (
sendMagicLink)
- Wichtig für Multi-Domain-Setup (
SITE_URL+APP_URL):- Verify-Links aus Better Auth werden vor dem Versand auf die App-Origin umgeschrieben (
toAuthAppUrl(...)), damit Session-Cookies auf der korrekten Origin gesetzt werden. - Ohne dieses Umschreiben kann Login per Magic Link zwar erfolgreich sein, aber das Dashboard in einem permanenten Loading-State hängen (fehlende Session auf App-Origin).
- Verify-Links aus Better Auth werden vor dem Versand auf die App-Origin umgeschrieben (
Auth-Race-Härtung
canvases.getnutzt optionalen Auth-Check und gibt bei fehlender Sessionnullzurück (statt Throw), damit SSR/Client-Hydration bei kurzem Token-Race nicht in404kippt.canvases.listgibt bei fehlender Session eine leere Liste zurück (statt Throw), damit Dashboard-Subscriptions beim Logout keinen Error-Spam erzeugen.credits.getBalancegibt bei fehlender Session einen Default-Stand (0-Werte) zurück (statt Throw), damit UI-Widgets nicht mitUnauthenticatedfehlschlagen.credits.getSubscriptionfällt bei fehlender Session auf Free/Active zurück (statt Throw), damit Tier-UI stabil bleibt.credits.getRecentTransactionsgibt bei fehlender Session[]zurück (statt Throw), damit Aktivitätslisten beim Logout sauber leeren.credits.getUsageStatsgibt bei fehlender Session0-Statistiken zurück (statt Throw), damit Verbrauchsanzeigen ohne Fehler ausrendern.dashboard.getSnapshotgibt bei fehlender Session einen vollständigen Default-Snapshot zurück (Balance 0, Free-Tier, leere Listen)
Idempotente Canvas-Mutations
nodes.create,nodes.createWithEdgeSplit,nodes.createWithEdgeFromSource,nodes.createWithEdgeToTargetsind überclientRequestIdidempotent.edges.createist überclientRequestIdidempotent.nodes.splitEdgeAtExistingNodeist überclientRequestIdidempotent (Replay wird als No-op behandelt).nodes.batchRemoveist idempotent tolerant: wenn alle angefragten Nodes bereits entfernt sind, wird die Mutation als No-op beendet.
Nodes & Edges (nodes.ts / edges.ts)
Nodes (nodes.ts)
Validierung:
getValidatedBatchNodesOrThrow()— Validiert Batch von Nodes mitvalidateBatchNodesForUserOrThrow()- Verwendet
canvas-connection-policy.tsfür Verbindungsberechtigungen
Mutationen:
create,update,delete— Standard CRUDcreateWithEdgeSplit,createWithEdgeFromSource,createWithEdgeToTarget— Erstellen mit Edge-VerbindungbatchRemove,batchRemoveNodes— Batch-EntfernungsplitEdgeAtExistingNode— Split einer Edge am existierenden Node
Optimistische IDs:
- Nodes erhalten temporäre IDs mit
optimistic_-Prefix für optimistic updates
Bridge-Edges:
- Bei Node-Löschung werden
computeBridgeCreatesForDeletedNodesincanvas-utils.tsverwendet, um Kanten neu zu verbinden
Edges (edges.ts)
Validierung:
assertConnectionPolicy()— Prüft, ob Source-Node Output erlaubt und Target-Node Input erlaubtassertTargetAllowsIncomingEdge()— Performance-optimierte Prüfung auf eingehende EdgesgetCanvasConnectionValidationMessage()— Fehlermeldung bei ungültigen Verbindungen
Validierungsregeln (aus canvas-connection-policy.ts):
- Source-Typ muss Output-Ports haben, Target-Typ muss Input-Ports haben
- Keine self-loops (Edge von Node zu sich selbst)
- Quelle:
image,text,note,group,compare,frame→ Source-Ports - Ziel:
ai-image,ai-video,compare→ Target-Ports video-prompt→ai-video✅ (einzige gültige Kombination für Video-Flow)ai-videoals Source für andere Nodes → ❌ (nur Compare)- Curves- und Adjustment-Node-Presets: Nur Presets nutzen, keine direkten Edges
Storage (storage.ts)
generateUploadUrlbleibt eine normale Mutation für Upload-Start im Client.batchGetUrlsForCanvasist absichtlich keine reaktive Query mehr, sondern eine Mutation. Der Canvas ruft sie gezielt an, wenn sich das aktuelle Set vonstorageIds geändert hat.- Eingabe:
canvasId+ client-seitig ermitteltestorageIds. - Server-seitig werden die angefragten IDs gegen die aktuellen Nodes des Canvas verifiziert, bevor
ctx.storage.getUrl(...)aufgerufen wird. - Ziel der Änderung: weniger Query-Fanout und weniger Canvas-weite Requery-Last bei jedem Node-/Edge-Update.
Dashboard Snapshot (dashboard.ts)
Gebündelte Query, die alle Dashboard-relevanten Daten in einem einzigen Convex-Call lädt. Ersetzt separate Queries für Balance, Subscription, UsageStats, Transactions und Canvases.
getSnapshot (query, public):
- Nutzt
optionalAuth— gibt bei fehlender Session Default-Werte zurück (kein Throw) - Lädt parallel:
creditBalances,subscriptions,creditTransactions(usage-type),creditTransactions(recent, max 80),canvases - Berechnet
monthlyUsageundtotalGenerationsaus usage-Transactions des aktuellen Monats - Nutzt
prioritizeRecentCreditTransactionsauslib/credits-activity.tsfür sortierte Transaktionsliste - Gibt strukturiertes Snapshot-Objekt zurück:
{ balance, subscription, usageStats, recentTransactions, canvases, generatedAt }
Canvas Graph Query (canvasGraph.ts)
Performance-optimierte Query, die Nodes und Edges für einen Canvas in einem einzigen Call lädt. Wird vom Frontend über den Canvas Graph Query Cache verwendet.
loadCanvasGraph(ctx, { canvasId, userId }) (internal):
- Prüft Canvas-Existenz und Ownership
- Lädt Nodes und Edges parallel via
Promise.all - Performance-Logging bei Queries > 250ms
get (query, public):
- Nutzt
requireAuth - Delegiert an
loadCanvasGraph - Loggt bei langsamer Ausführung (
PERFORMANCE_LOG_THRESHOLD_MS = 250)
Frontend-Integration: Der Canvas nutzt diese Query über canvas-graph-query-cache.ts (Optimistic Store Helper).
Konventionen
internalMutation/internalAction— Nur von anderen Convex-Funktionen aufrufbar, nicht direkt vom Client.mutation/query/action— Öffentlich, direkt vom Frontend nutzbar.- Alle User-Daten sind über
userId(Better Auth User ID, kein Convex-eigenes User-Dokument) indiziert. - Schema-Migrationen:
npx convex deverkennt Breaking Changes automatisch. Beiv.any()-Feldern gibt es keine automatische Migration — Datensätze müssen manuell/scriptmäßig migriert werden.