project setup with core files including configuration, package management, and basic structure. Added .gitignore, README, and various TypeScript types for CMS components. Implemented initial components and layouts for the application.

This commit is contained in:
Peter Meier
2025-12-13 23:26:13 +01:00
parent ea288a5bbc
commit b1a556dc6d
167 changed files with 19057 additions and 131 deletions

View File

@@ -0,0 +1,82 @@
# Redis Cache Setup
## Lokale Installation
### macOS (mit Homebrew)
```bash
brew install redis
brew services start redis
```
### Docker
```bash
docker run -d -p 6379:6379 --name redis redis:latest
```
### Linux (Ubuntu/Debian)
```bash
sudo apt-get update
sudo apt-get install redis-server
sudo systemctl start redis
```
## Konfiguration
### Environment Variables
```bash
# Redis aktivieren
REDIS_ENABLED=true
# Redis-Verbindung (optional, Defaults: localhost:6379)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD= # Optional, nur wenn gesetzt
```
### Beispiel .env Datei
```env
REDIS_ENABLED=true
REDIS_HOST=localhost
REDIS_PORT=6379
```
## Verwendung
### Redis aktivieren
```bash
REDIS_ENABLED=true npm run mock:server
```
### Ohne Redis (In-Memory Fallback)
```bash
# REDIS_ENABLED nicht setzen oder auf false
npm run mock:server
```
## Features
-**Automatischer Fallback**: Wenn Redis nicht verfügbar ist, wird automatisch In-Memory Cache verwendet
-**Retry-Logik**: Bei Verbindungsfehlern wird automatisch auf In-Memory umgeschaltet
-**Metrics**: Cache-Hits/Misses werden weiterhin getrackt
-**Logging**: Alle Redis-Operationen werden geloggt
## Testen
```bash
# Redis-Verbindung testen
redis-cli ping
# Sollte "PONG" zurückgeben
# Cache-Keys anzeigen
redis-cli keys "*"
# Cache leeren
redis-cli FLUSHALL
```
## Monitoring
Die Cache-Metriken sind weiterhin verfügbar unter:
- `http://localhost:9090/metrics` - Prometheus Metrics
- Logs zeigen Redis-Status und Fallback-Verhalten

View File

@@ -0,0 +1,82 @@
import { cacheConfig } from "../config/cache.js";
import { cacheHits, cacheMisses } from "../monitoring/metrics.js";
import { logCacheHit, logCacheMiss } from "../monitoring/logger.js";
import { RedisCache } from "./redisCache.js";
/**
* Cache-Interface für Abstraktion
*/
export interface CacheInterface<T> {
set(key: string, data: T, ttl?: number): void | Promise<void>;
get(key: string): T | null | Promise<T | null>;
clear(): void | Promise<void>;
delete(key: string): void | Promise<void>;
}
/**
* In-Memory Cache (Fallback wenn Redis nicht verfügbar)
*/
class InMemoryCache<T> implements CacheInterface<T> {
private cache = new Map<string, { data: T; expiresAt: number }>();
private defaultTTL: number;
private cacheType: string;
constructor(defaultTTL: number, cacheType: string) {
this.defaultTTL = defaultTTL;
this.cacheType = cacheType;
}
async set(key: string, data: T, ttl?: number): Promise<void> {
const expiresAt = Date.now() + (ttl || this.defaultTTL);
this.cache.set(key, { data, expiresAt });
}
async get(key: string): Promise<T | null> {
const entry = this.cache.get(key);
if (!entry) {
cacheMisses.inc({ cache_type: this.cacheType });
logCacheMiss(key, this.cacheType);
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
cacheMisses.inc({ cache_type: this.cacheType });
logCacheMiss(key, this.cacheType);
return null;
}
cacheHits.inc({ cache_type: this.cacheType });
logCacheHit(key, this.cacheType);
return entry.data;
}
async clear(): Promise<void> {
this.cache.clear();
}
async delete(key: string): Promise<void> {
this.cache.delete(key);
}
}
/**
* Cache-Instanzen
* Verwendet Redis wenn aktiviert, sonst In-Memory
*/
const useRedis = process.env.REDIS_ENABLED === "true";
export const cache = {
pages: useRedis
? new RedisCache<any>(cacheConfig.pages.ttl, "pages")
: new InMemoryCache<any>(cacheConfig.pages.ttl, "pages"),
pageSeo: useRedis
? new RedisCache<any>(cacheConfig.pageSeo.ttl, "pageSeo")
: new InMemoryCache<any>(cacheConfig.pageSeo.ttl, "pageSeo"),
navigation: useRedis
? new RedisCache<any>(cacheConfig.navigation.ttl, "navigation")
: new InMemoryCache<any>(cacheConfig.navigation.ttl, "navigation"),
products: useRedis
? new RedisCache<any>(cacheConfig.products.ttl, "products")
: new InMemoryCache<any>(cacheConfig.products.ttl, "products"),
};

View File

@@ -0,0 +1,33 @@
/**
* Zentralisierte Cache-Key-Generierung
* Stellt sicher, dass Cache-Keys konsistent und wartbar sind
*/
export class CacheKeyBuilder {
static page(slug: string, locale?: string): string {
return `page:${slug}:${locale || "default"}`;
}
static pages(locale?: string): string {
return `pages:all:${locale || "default"}`;
}
static pageSeo(locale?: string): string {
return `pageSeo:${locale || "default"}`;
}
static navigation(locale?: string): string {
return `navigation:${locale || "default"}`;
}
static product(id: string): string {
return `product:${id}`;
}
static products(limit?: number): string {
return `products:${limit || "default"}`;
}
static translations(locale: string, namespace?: string): string {
return `translations:${locale}${namespace ? `:${namespace}` : ""}`;
}
}

View File

@@ -0,0 +1,80 @@
import {
dataServiceCalls,
dataServiceDuration,
} from "../monitoring/metrics.js";
import { logger } from "../monitoring/logger.js";
import { AdapterError } from "./errors.js";
import type { CacheInterface } from "./cache.js";
/**
* Helper für DataService-Methoden mit Cache und Metrics
*/
export class DataServiceHelpers {
/**
* Führt eine DataService-Operation mit Cache und Metrics aus
*/
static async withCacheAndMetrics<T>(
method: string,
cache: CacheInterface<T>,
cacheKey: string,
operation: () => Promise<T>,
errorMessage: string,
context?: Record<string, unknown>
): Promise<T> {
const startTime = Date.now();
// Prüfe Cache
const cached = await cache.get(cacheKey);
if (cached) {
dataServiceCalls.inc({ method, status: "success" });
dataServiceDuration.observe({ method }, (Date.now() - startTime) / 1000);
return cached;
}
try {
// Führe Operation aus
const result = await operation();
// Speichere im Cache (wenn Ergebnis vorhanden)
if (result) {
await cache.set(cacheKey, result);
}
// Metrics
dataServiceCalls.inc({ method, status: "success" });
dataServiceDuration.observe({ method }, (Date.now() - startTime) / 1000);
return result;
} catch (error) {
// Error Metrics
dataServiceCalls.inc({ method, status: "error" });
dataServiceDuration.observe({ method }, (Date.now() - startTime) / 1000);
logger.error(`Error in ${method}`, { ...context, error });
throw new AdapterError(errorMessage, error);
}
}
/**
* Führt eine DataService-Operation nur mit Cache aus (ohne Metrics)
*/
static async withCache<T>(
cache: CacheInterface<T>,
cacheKey: string,
operation: () => Promise<T>,
errorMessage: string
): Promise<T> {
const cached = await cache.get(cacheKey);
if (cached) {
return cached;
}
try {
const result = await operation();
await cache.set(cacheKey, result);
return result;
} catch (error) {
throw new AdapterError(errorMessage, error);
}
}
}

View File

@@ -0,0 +1,89 @@
import DataLoader from "dataloader";
import type { Product, Page } from "../types/index.js";
import type { User } from "../types/user.js";
import { dataService } from "../dataService.js";
import { userService } from "../auth/userService.js";
/**
* Dataloader für Batch-Loading von Produkten
* Verhindert N+1 Queries, wenn mehrere Produkte in einer Query abgefragt werden
*/
export const createProductLoader = () => {
return new DataLoader<string, Product | null>(
async (ids: readonly string[]) => {
// Batch-Loading: Lade alle Produkte in einem Batch
const products = await Promise.all(
ids.map((id) => dataService.getProduct(id))
);
// Stelle sicher, dass die Reihenfolge mit den IDs übereinstimmt
return products;
}
);
};
/**
* Dataloader für Batch-Loading von Seiten
* Unterstützt Locale im Key-Format: "slug:locale" oder "slug"
*/
export const createPageLoader = () => {
return new DataLoader<string, Page | null>(
async (keys: readonly string[]) => {
const pages = await Promise.all(
keys.map((key) => {
// Parse Key: Format kann "slug:locale" oder "slug" sein
const parts = key.split(":");
const slug = parts[0];
const locale = parts.length > 1 ? parts[1] : undefined;
return dataService.getPage(slug, locale);
})
);
return pages;
}
);
};
/**
* Dataloader für Batch-Loading von Usern
*/
export const createUserLoader = () => {
return new DataLoader<string, User | null>(
async (userIds: readonly string[]) => {
const users = await Promise.all(
userIds.map((id) => userService.getUserById(id))
);
return users;
}
);
};
/**
* Context für GraphQL Resolver
* Enthält Dataloader-Instanzen und User-Informationen
*
* Der Context wird für jeden GraphQL Request erstellt und enthält:
* - `user`: Aktueller authentifizierter User (oder null)
* - `loaders`: Dataloader-Instanzen für Batch-Loading (verhindert N+1 Queries)
*/
export interface GraphQLContext {
user: User | null;
loaders: {
product: DataLoader<string, Product | null>;
page: DataLoader<string, Page | null>;
user: DataLoader<string, User | null>;
};
}
/**
* Erstellt einen neuen Context mit Dataloadern und User
*/
export const createContext = (user: User | null = null): GraphQLContext => {
return {
user,
loaders: {
product: createProductLoader(),
page: createPageLoader(),
user: createUserLoader(),
},
};
};

View File

@@ -0,0 +1,28 @@
/**
* Custom Error-Klassen für den Middlelayer
*/
export class DataServiceError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly originalError?: unknown
) {
super(message);
this.name = 'DataServiceError';
}
}
export class AdapterError extends DataServiceError {
constructor(message: string, originalError?: unknown) {
super(message, 'ADAPTER_ERROR', originalError);
this.name = 'AdapterError';
}
}
export class NotFoundError extends DataServiceError {
constructor(resource: string, identifier: string) {
super(`${resource} mit ID '${identifier}' nicht gefunden`, 'NOT_FOUND');
this.name = 'NotFoundError';
}
}

View File

@@ -0,0 +1,175 @@
import Redis from "ioredis";
import { cacheHits, cacheMisses } from "../monitoring/metrics.js";
import { logCacheHit, logCacheMiss } from "../monitoring/logger.js";
import { logger } from "../monitoring/logger.js";
/**
* Redis-basierter Cache mit Fallback auf In-Memory
*/
export class RedisCache<T> {
private redis: Redis | null = null;
private memoryCache: Map<string, { data: T; expiresAt: number }> = new Map();
private defaultTTL: number;
private cacheType: string;
private useRedis: boolean;
constructor(defaultTTL: number, cacheType: string) {
this.defaultTTL = defaultTTL;
this.cacheType = cacheType;
this.useRedis = process.env.REDIS_ENABLED === "true";
if (this.useRedis) {
this.initRedis();
} else {
logger.info(
`Redis Cache deaktiviert für ${cacheType}, verwende In-Memory Cache`
);
}
}
private async initRedis() {
try {
this.redis = new Redis({
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT || "6379"),
password: process.env.REDIS_PASSWORD,
retryStrategy: (times) => {
// Bei Fehler: Fallback auf In-Memory nach 3 Versuchen
if (times > 3) {
logger.warn(
"Redis-Verbindung fehlgeschlagen, verwende In-Memory Cache"
);
this.useRedis = false;
return null; // Stoppe Retry
}
return Math.min(times * 50, 2000);
},
maxRetriesPerRequest: 3,
});
this.redis.on("error", (error) => {
logger.error("Redis-Fehler", { error, cacheType: this.cacheType });
// Fallback auf In-Memory bei Fehler
this.useRedis = false;
});
this.redis.on("connect", () => {
logger.info(`Redis verbunden für ${this.cacheType}`);
});
// Test-Verbindung
await this.redis.ping();
logger.info(`Redis Cache aktiviert für ${this.cacheType}`);
} catch (error) {
logger.warn(
"Redis-Initialisierung fehlgeschlagen, verwende In-Memory Cache",
{
error,
cacheType: this.cacheType,
}
);
this.useRedis = false;
}
}
async set(key: string, data: T, ttl?: number): Promise<void> {
const ttlMs = ttl || this.defaultTTL;
const serialized = JSON.stringify(data);
if (this.useRedis && this.redis) {
try {
// Redis TTL in Sekunden
const ttlSeconds = Math.ceil(ttlMs / 1000);
await this.redis.setex(key, ttlSeconds, serialized);
return;
} catch (error) {
logger.warn("Redis set fehlgeschlagen, verwende In-Memory", {
error,
key,
});
this.useRedis = false;
}
}
// Fallback: In-Memory
const expiresAt = Date.now() + ttlMs;
this.memoryCache.set(key, { data, expiresAt });
}
async get(key: string): Promise<T | null> {
if (this.useRedis && this.redis) {
try {
const value = await this.redis.get(key);
if (value) {
// Cache Hit
cacheHits.inc({ cache_type: this.cacheType });
logCacheHit(key, this.cacheType);
return JSON.parse(value) as T;
}
// Cache Miss
cacheMisses.inc({ cache_type: this.cacheType });
logCacheMiss(key, this.cacheType);
return null;
} catch (error) {
logger.warn("Redis get fehlgeschlagen, verwende In-Memory", {
error,
key,
});
this.useRedis = false;
}
}
// Fallback: In-Memory
const entry = this.memoryCache.get(key);
if (!entry) {
cacheMisses.inc({ cache_type: this.cacheType });
logCacheMiss(key, this.cacheType);
return null;
}
if (Date.now() > entry.expiresAt) {
this.memoryCache.delete(key);
cacheMisses.inc({ cache_type: this.cacheType });
logCacheMiss(key, this.cacheType);
return null;
}
cacheHits.inc({ cache_type: this.cacheType });
logCacheHit(key, this.cacheType);
return entry.data;
}
async clear(): Promise<void> {
if (this.useRedis && this.redis) {
try {
// Lösche alle Keys mit unserem Prefix
const keys = await this.redis.keys(`${this.cacheType}:*`);
if (keys.length > 0) {
await this.redis.del(...keys);
}
} catch (error) {
logger.warn("Redis clear fehlgeschlagen", { error });
}
}
this.memoryCache.clear();
}
async delete(key: string): Promise<void> {
if (this.useRedis && this.redis) {
try {
await this.redis.del(key);
return;
} catch (error) {
logger.warn("Redis delete fehlgeschlagen", { error, key });
}
}
this.memoryCache.delete(key);
}
async disconnect(): Promise<void> {
if (this.redis) {
await this.redis.quit();
this.redis = null;
}
}
}