Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/app/services/data-cache.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Injectable } from '@angular/core'

type CacheEntry<T> = {
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<string, CacheEntry<unknown>>()
private readonly inFlight = new Map<string, Promise<unknown>>()

/**
* Returns the cached value if present and still valid.
* Expired entries are evicted eagerly.
*/
get<T>(key: string): T | undefined {
const entry = this.store.get(key) as CacheEntry<T> | undefined
if (!entry) return undefined
if (entry.expiresAt <= Date.now()) {
this.store.delete(key)
return undefined
}
return entry.value
}

set<T>(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<T>(key: string, fetcher: () => Promise<T>, ttlMs: number): Promise<T> {
const cached = this.get<T>(key)
if (cached !== undefined) {
return cached
}

if (this.inFlight.has(key)) {
return (this.inFlight.get(key) as Promise<T>)!
}

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<unknown>)
return request
}

/**
* Alias that keeps older call sites readable until they are updated to `getOrFetch`.
*/
async remember<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
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()
}
}
131 changes: 119 additions & 12 deletions src/app/services/surrealdb.service.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,65 @@
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',
})
export class SurrealdbService extends Surreal {
private connectionInitialized = false
private connectionPromise: Promise<void> | 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<string> | 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<string> | StringRecordId): string {
return `record:${this.recordIdToString(recordId)}`
}

private queryKey(sql: string, params?: Record<string, unknown>): string {
const serialized =
params === undefined
? ''
: `:${JSON.stringify(
Object.keys(params)
.sort()
.reduce<Record<string, unknown>>((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) {
Expand Down Expand Up @@ -55,6 +101,7 @@ export class SurrealdbService extends Surreal {
password: password,
},
})
this.cache.clear()
return jwtToken
}

Expand All @@ -64,25 +111,40 @@ export class SurrealdbService extends Surreal {
return await super.authenticate(token)
}

async getByRecordId<T extends Record<string, unknown>>(recordId: RecordId<string> | StringRecordId): Promise<T> {
async getByRecordId<T extends Record<string, unknown>>(
recordId: RecordId<string> | StringRecordId,
options?: CacheOptions,
): Promise<T> {
// Stelle sicher, dass die Verbindung initialisiert ist
await this.initialize()
const result = await super.select<T>(recordId)
return result as T
return await this.fetchCached(
this.recordKey(recordId),
this.defaultTtlMs,
async () => (await super.select<T>(recordId)) as T,
options,
)
}

// 2) Alle Einträge einer Tabelle holen
async getAll<T extends Record<string, unknown>>(table: string): Promise<T[]> {
async getAll<T extends Record<string, unknown>>(table: string, options?: CacheOptions): Promise<T[]> {
// Stelle sicher, dass die Verbindung initialisiert ist
await this.initialize()
return await super.select<T>(table)
return await this.fetchCached(
this.tableKey(table),
this.defaultTtlMs,
async () => await super.select<T>(table),
options,
)
}

// 3) Einfügen und die neuen Datensätze zurückbekommen
async post<T extends Record<string, unknown>>(table: string, payload?: T | T[]): Promise<T[]> {
// Stelle sicher, dass die Verbindung initialisiert ist
await this.initialize()
return await super.insert<T>(table, payload)
const result = await super.insert<T>(table, payload)
this.cache.invalidate(this.tableKey(table))
this.cache.invalidatePrefix('query:')
return result
}

async postUpdate<T extends Record<string, unknown>>(id: RecordId<string> | StringRecordId, payload?: T): Promise<T> {
Expand All @@ -91,6 +153,13 @@ export class SurrealdbService extends Surreal {
await this.initialize()
const updatedRecord = await super.update<T>(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)
Expand All @@ -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<AppEvent[]> {
Expand Down Expand Up @@ -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<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>,
options?: CacheOptions,
): Promise<T> {
if (this.shouldBypassCache(options)) {
return await fetcher()
}
return await this.cache.getOrFetch<T>(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
}
}
Loading