- Updated version from v1.5 to v2.0 in both the LemonSpace Manifest and PRD documents. - Expanded Phase 1 scope to include video and asset nodes, and integrated non-destructive image editing capabilities. - Enhanced node taxonomy to reflect 6 categories with 27 node types. - Added details on offline sync features and optimistic updates in the documentation. - Improved clarity and structure of the product vision and problem statement sections.
12 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-Bildgenerierungs-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 |
storage.ts |
Convex File Storage Helpers + gebündelte Canvas-URL-Auflösung |
export.ts |
Canvas-Export-Logik |
http.ts |
HTTP-Endpunkte (Webhooks) |
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 |
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.
subscriptions — Aktive Subscription. Tier: free | starter | pro | max | business.
dailyUsage — Täglicher Zähler pro User für Abuse-Prevention. Key: userId + date (ISO).
AI-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.
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).
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.
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,compare→ Target-Ports - 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.
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.