diff --git a/src/app/services/data-cache.service.ts b/src/app/services/data-cache.service.ts new file mode 100644 index 00000000..3dad4984 --- /dev/null +++ b/src/app/services/data-cache.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@angular/core' + +type CacheEntry = { + value: T + expiresAt: number +} + +@Injectable({ providedIn: 'root' }) +export class DataCacheService { +/** + * Lightweight in-memory TTL cache used by {@link SurrealdbService}. + * + * Warum existiert dieser Service? + * - Der SurrealDB-Service braucht ein kleines Cache-Overlay, um doppelte SELECT/QUERY-Requests + * im öffentlichen Bereich zu reduzieren (Kategorie-/Detailseiten, Volltextsuche). + * - Für Admin-/Schreiboperationen wird der Cache entweder komplett invalidiert (Login, Mutationen) + * oder bewusst umgangen (SurrealdbService prüft die aktuelle Route). Dadurch sehen Admins immer + * Live-Daten. + * - Der Cache bleibt bewusst in einem eigenen Service, damit: + * 1. die Logik testbar und wiederverwendbar bleibt, + * 2. keine Angular-spezifischen Abhängigkeiten (Router, environment) in diesen Store wandern, + * 3. wir bei Bedarf eine andere Persistenzstrategie hinterlegen könnten, ohne den Surreal-Service + * anzupassen. + * + * Architektur: + * - `store`: Map-Key → { value, expiresAt } für schnelle TTL-Lookups. + * - `inFlight`: Promise-Dedupe, damit parallele `getOrFetch`-Aufrufe dieselbe Anfrage teilen. + * - Keine Persistenz (IndexedDB/localStorage), damit Logout/Sitzungswechsel garantiert frische Daten + * liefert und der Service SSR-/Node-Tests nicht blockiert. + */ + private readonly store = new Map>() + private readonly inFlight = new Map>() + + /** + * Returns the cached value if present and still valid. + * Expired entries are evicted eagerly. + */ + get(key: string): T | undefined { + const entry = this.store.get(key) as CacheEntry | undefined + if (!entry) return undefined + if (entry.expiresAt <= Date.now()) { + this.store.delete(key) + return undefined + } + return entry.value + } + + set(key: string, value: T, ttlMs: number): void { + if (value === undefined || value === null) { + this.store.delete(key) + return + } + const expiresAt = Date.now() + ttlMs + this.store.set(key, { value, expiresAt }) + } + + /** + * Returns cached data or executes the provided fetcher while deduplicating concurrent calls. + * The result is stored for the specified TTL unless the fetcher resolves to null/undefined. + */ + async getOrFetch(key: string, fetcher: () => Promise, ttlMs: number): Promise { + const cached = this.get(key) + if (cached !== undefined) { + return cached + } + + if (this.inFlight.has(key)) { + return (this.inFlight.get(key) as Promise)! + } + + const request = fetcher() + .then((result) => { + this.set(key, result, ttlMs) + return result + }) + .finally(() => { + this.inFlight.delete(key) + }) + + this.inFlight.set(key, request as unknown as Promise) + return request + } + + /** + * Alias that keeps older call sites readable until they are updated to `getOrFetch`. + */ + async remember(key: string, ttlMs: number, fetcher: () => Promise): Promise { + return await this.getOrFetch(key, fetcher, ttlMs) + } + + /** Removes a single cache entry plus any inflight request for the same key. */ + invalidate(key: string): void { + this.store.delete(key) + this.inFlight.delete(key) + } + + /** Bulk invalidation helper used when tables or generic queries change. */ + invalidatePrefix(prefix: string): void { + for (const key of this.store.keys()) { + if (key.startsWith(prefix)) { + this.store.delete(key) + } + } + for (const key of this.inFlight.keys()) { + if (key.startsWith(prefix)) { + this.inFlight.delete(key) + } + } + } + + /** Clears the entire cache – typically used during logout. */ + clear(): void { + this.store.clear() + this.inFlight.clear() + } +} diff --git a/src/app/services/surrealdb.service.ts b/src/app/services/surrealdb.service.ts index cf95d2d0..9f3dc4e0 100644 --- a/src/app/services/surrealdb.service.ts +++ b/src/app/services/surrealdb.service.ts @@ -1,7 +1,13 @@ -import { Injectable } from '@angular/core' +import { Injectable, inject } from '@angular/core' +import { Router } from '@angular/router' import Surreal, { RecordId, StringRecordId, Token } from 'surrealdb' import { environment } from '../../environments/environment' import { Event as AppEvent } from '../models/event.interface' +import { DataCacheService } from './data-cache.service' + +type CacheOptions = { + bypassCache?: boolean +} @Injectable({ providedIn: 'root', @@ -9,11 +15,51 @@ import { Event as AppEvent } from '../models/event.interface' export class SurrealdbService extends Surreal { private connectionInitialized = false private connectionPromise: Promise | null = null + private readonly cache = inject(DataCacheService) + private readonly router = inject(Router, { optional: true }) + private readonly defaultTtlMs = 60_000 + private readonly searchTtlMs = 10_000 constructor() { super() } + private recordIdToString(recordId: RecordId | StringRecordId): string { + const asString = (recordId as unknown as { toString?: () => string })?.toString?.() + if (typeof asString === 'string') { + return asString + } + const tb = (recordId as { tb?: string })?.tb + const id = (recordId as { id?: unknown })?.id + if (tb && id !== undefined) { + return `${tb}:${id}` + } + return String(recordId) + } + + private tableKey(table: string): string { + return `table:${table}` + } + + private recordKey(recordId: RecordId | StringRecordId): string { + return `record:${this.recordIdToString(recordId)}` + } + + private queryKey(sql: string, params?: Record): string { + const serialized = + params === undefined + ? '' + : `:${JSON.stringify( + Object.keys(params) + .sort() + .reduce>((acc, key) => { + acc[key] = params[key] + return acc + }, {}), + )}` + return `query:${sql}${serialized}` + } + async initialize() { // Wenn bereits eine Initialisierung läuft, warte auf deren Abschluss if (this.connectionPromise) { @@ -55,6 +101,7 @@ export class SurrealdbService extends Surreal { password: password, }, }) + this.cache.clear() return jwtToken } @@ -64,25 +111,40 @@ export class SurrealdbService extends Surreal { return await super.authenticate(token) } - async getByRecordId>(recordId: RecordId | StringRecordId): Promise { + async getByRecordId>( + recordId: RecordId | StringRecordId, + options?: CacheOptions, + ): Promise { // Stelle sicher, dass die Verbindung initialisiert ist await this.initialize() - const result = await super.select(recordId) - return result as T + return await this.fetchCached( + this.recordKey(recordId), + this.defaultTtlMs, + async () => (await super.select(recordId)) as T, + options, + ) } // 2) Alle Einträge einer Tabelle holen - async getAll>(table: string): Promise { + async getAll>(table: string, options?: CacheOptions): Promise { // Stelle sicher, dass die Verbindung initialisiert ist await this.initialize() - return await super.select(table) + return await this.fetchCached( + this.tableKey(table), + this.defaultTtlMs, + async () => await super.select(table), + options, + ) } // 3) Einfügen und die neuen Datensätze zurückbekommen async post>(table: string, payload?: T | T[]): Promise { // Stelle sicher, dass die Verbindung initialisiert ist await this.initialize() - return await super.insert(table, payload) + const result = await super.insert(table, payload) + this.cache.invalidate(this.tableKey(table)) + this.cache.invalidatePrefix('query:') + return result } async postUpdate>(id: RecordId | StringRecordId, payload?: T): Promise { @@ -91,6 +153,13 @@ export class SurrealdbService extends Surreal { await this.initialize() const updatedRecord = await super.update(id, payload) + const table = (id as { tb?: string })?.tb + this.cache.invalidate(this.recordKey(id)) + if (table) { + this.cache.invalidate(this.tableKey(table)) + } + this.cache.invalidatePrefix('query:') + return updatedRecord } catch (error) { console.error('Error in postUpdate:', error) @@ -102,6 +171,13 @@ export class SurrealdbService extends Surreal { // Stelle sicher, dass die Verbindung initialisiert ist await this.initialize() await super.delete(recordId) + + const table = (recordId as { tb?: string })?.tb + this.cache.invalidate(this.recordKey(recordId)) + if (table) { + this.cache.invalidate(this.tableKey(table)) + } + this.cache.invalidatePrefix('query:') } async fulltextSearchEvents(searchTerm: string): Promise { @@ -139,14 +215,45 @@ export class SurrealdbService extends Surreal { LIMIT 30;` try { - const result = (await super.query(ftsSql, { 'q': q }))[0] as AppEvent[] // Index 0, da nur eine, erste Query im Batch - - if (result.length > 0) { - return result - } + const result = await this.fetchCached( + this.queryKey(ftsSql, { q }), + this.searchTtlMs, + async () => { + const queryResult = (await super.query(ftsSql, { 'q': q }))[0] as AppEvent[] + return Array.isArray(queryResult) ? queryResult : [] + }, + ) + return result } catch (err) { console.warn('[SurrealdbService] FTS query failed, will fallback', err) + return [] } return [] } + + private async fetchCached( + key: string, + ttlMs: number, + fetcher: () => Promise, + options?: CacheOptions, + ): Promise { + if (this.shouldBypassCache(options)) { + return await fetcher() + } + return await this.cache.getOrFetch(key, fetcher, ttlMs) + } + + private shouldBypassCache(options?: CacheOptions): boolean { + if (options?.bypassCache) { + return true + } + const url = this.router?.url + if (typeof url === 'string' && url.startsWith('/admin')) { + return true + } + if (typeof window !== 'undefined' && typeof window.location?.pathname === 'string') { + return window.location.pathname.startsWith('/admin') + } + return false + } }