From 59d4d10ca6e291e82510d3c37776b845e8a988c9 Mon Sep 17 00:00:00 2001 From: "Strittmatter, Stephan" Date: Thu, 13 Nov 2025 08:47:14 +0100 Subject: [PATCH 1/5] feat: add DataCacheService and cache reads/FTS in SurrealdbService Co-authored-by: aider (openai/gpt-5) --- src/app/services/data-cache.service.ts | 96 +++++++++++++++++++++ src/app/services/surrealdb.service.ts | 111 ++++++++++++++++++++++--- 2 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 src/app/services/data-cache.service.ts diff --git a/src/app/services/data-cache.service.ts b/src/app/services/data-cache.service.ts new file mode 100644 index 00000000..cb40bd70 --- /dev/null +++ b/src/app/services/data-cache.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@angular/core' + +type CacheEntry = { + value: T + expiresAt: number +} + +@Injectable({ providedIn: 'root' }) +export class DataCacheService { + private readonly store = new Map>() + private readonly inFlight = new Map>() + + 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 { + this.store.set(key, { value, expiresAt: Date.now() + ttlMs }) + } + + async getOrFetch(key: string, fetcher: () => Promise, ttlMs: number, swrMs = 0): Promise { + const now = Date.now() + const cached = this.store.get(key) as CacheEntry | undefined + + // Fresh cache + if (cached && cached.expiresAt > now) { + return cached.value + } + + // SWR: return stale immediately and refresh in background + if (cached && swrMs > 0 && cached.expiresAt <= now && cached.expiresAt + swrMs > now) { + if (!this.inFlight.has(key)) { + const bg = fetcher() + .then((value) => { + this.store.set(key, { value, expiresAt: Date.now() + ttlMs }) + this.inFlight.delete(key) + return value as unknown as T + }) + .catch(() => { + this.inFlight.delete(key) + return cached.value + }) + this.inFlight.set(key, bg) + } + return cached.value + } + + // Deduplicate concurrent fetches + if (this.inFlight.has(key)) { + return (this.inFlight.get(key) as Promise)! + } + + const p = fetcher() + .then((value) => { + this.store.set(key, { value, expiresAt: Date.now() + ttlMs }) + this.inFlight.delete(key) + return value + }) + .catch((err) => { + this.inFlight.delete(key) + throw err + }) + + this.inFlight.set(key, p as Promise) + return p + } + + invalidate(key: string): void { + this.store.delete(key) + this.inFlight.delete(key) + } + + invalidatePrefix(prefix: string): void { + for (const key of Array.from(this.store.keys())) { + if (key.startsWith(prefix)) { + this.store.delete(key) + } + } + for (const key of Array.from(this.inFlight.keys())) { + if (key.startsWith(prefix)) { + this.inFlight.delete(key) + } + } + } + + 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 fef83370..087c1e09 100644 --- a/src/app/services/surrealdb.service.ts +++ b/src/app/services/surrealdb.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@angular/core' +import { Injectable, inject } from '@angular/core' 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' @Injectable({ providedIn: 'root', @@ -10,10 +11,59 @@ export class SurrealdbService extends Surreal { private connectionInitialized = false private connectionPromise: Promise | null = null + // Lightweight in-memory cache to reduce duplicate DB requests + private readonly cache = inject(DataCacheService) + private readonly defaultTtlMs = 60_000 // 60s default cache for read operations + private readonly searchTtlMs = 10_000 // short cache for search results + constructor() { super() } + // Helper to stringify RecordId reliably + private recordIdToString(id: RecordId | StringRecordId): string { + try { + if (typeof id === 'string') return id + // Surreal's RecordId may implement toString() + return (id as any).toString?.() ?? `${(id as any).tb}:${(id as any).id}` + } catch { + return String(id) + } + } + + private tableFromRecordId(id: RecordId | StringRecordId): string | undefined { + try { + if (typeof id === 'string') return id.split(':')[0] + return (id as any).tb ?? undefined + } catch { + return undefined + } + } + + private tableKey(table: string): string { + return `table:${table}` + } + + private recordKey(id: RecordId | StringRecordId): string { + return `record:${this.recordIdToString(id)}` + } + + private stableStringify(obj: unknown): string { + if (obj == null || typeof obj !== 'object') return JSON.stringify(obj) + const keys = new Set() + JSON.stringify(obj as any, (k, v) => { + keys.add(k) + return v + }) + const sorted = Array.from(keys).sort() + return JSON.stringify(obj as any, sorted) + } + + private queryKey(sql: string, params?: unknown): string { + const p = params === undefined ? '' : `:${this.stableStringify(params)}` + return `query:${sql}${p}` + } + async initialize() { // Wenn bereits eine Initialisierung läuft, warte auf deren Abschluss if (this.connectionPromise) { @@ -67,22 +117,40 @@ export class SurrealdbService extends Surreal { async getByRecordId>(recordId: RecordId | StringRecordId): Promise { // Stelle sicher, dass die Verbindung initialisiert ist await this.initialize() - const result = await super.select(recordId) - return result as T + const key = this.recordKey(recordId) + return await this.cache.getOrFetch( + key, + async () => { + const result = await super.select(recordId) + return result as T + }, + this.defaultTtlMs, + ) } // 2) Alle Einträge einer Tabelle holen async getAll>(table: string): Promise { // Stelle sicher, dass die Verbindung initialisiert ist await this.initialize() - return await super.select(table) + const key = this.tableKey(table) + return await this.cache.getOrFetch( + key, + async () => { + return await super.select(table) + }, + this.defaultTtlMs, + ) } // 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 res = await super.insert(table, payload) + // Invalidate caches related to this table and generic queries + this.cache.invalidate(this.tableKey(table)) + this.cache.invalidatePrefix('query:') + return res } async postUpdate>(id: RecordId | StringRecordId, payload?: T): Promise { @@ -91,6 +159,12 @@ export class SurrealdbService extends Surreal { await this.initialize() const updatedRecord = await super.update(id, payload) + // Invalidate caches for this record and its table + const table = this.tableFromRecordId(id) + 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) @@ -99,7 +173,15 @@ export class SurrealdbService extends Surreal { } async deleteRow(recordId: RecordId | StringRecordId) { + // Stelle sicher, dass die Verbindung initialisiert ist + await this.initialize() await super.delete(recordId) + + // Invalidate caches for this record and its table + const table = this.tableFromRecordId(recordId) + this.cache.invalidate(this.recordKey(recordId)) + if (table) this.cache.invalidate(this.tableKey(table)) + this.cache.invalidatePrefix('query:') } async fulltextSearchEvents(searchTerm: string): Promise { @@ -136,15 +218,22 @@ export class SurrealdbService extends Surreal { ORDER BY relevance DESC LIMIT 30;` - try { - const result = (await super.query(ftsSql, { 'q': q }))[0] as AppEvent[] // Index 0, da nur eine, erste Query im Batch + const key = this.queryKey(ftsSql, { q }) - if (result.length > 0) { - return result - } + try { + const result = await this.cache.getOrFetch( + key, + async () => { + const res = (await super.query(ftsSql, { 'q': q }))[0] as AppEvent[] + return Array.isArray(res) ? res : [] + }, + this.searchTtlMs, + this.searchTtlMs, // allow brief SWR window to throttle rapid typing + ) + return result } catch (err) { console.warn('[SurrealdbService] FTS query failed, will fallback', err) + return [] } - return [] } } From de1f7ad6c4e375417aabc6c688fd7b219b87756c Mon Sep 17 00:00:00 2001 From: "Strittmatter, Stephan" Date: Thu, 13 Nov 2025 09:01:22 +0100 Subject: [PATCH 2/5] fix: key generation --- src/app/services/surrealdb.service.ts | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/app/services/surrealdb.service.ts b/src/app/services/surrealdb.service.ts index 087c1e09..a0b7927c 100644 --- a/src/app/services/surrealdb.service.ts +++ b/src/app/services/surrealdb.service.ts @@ -20,32 +20,18 @@ export class SurrealdbService extends Surreal { super() } - // Helper to stringify RecordId reliably - private recordIdToString(id: RecordId | StringRecordId): string { - try { - if (typeof id === 'string') return id - // Surreal's RecordId may implement toString() - return (id as any).toString?.() ?? `${(id as any).tb}:${(id as any).id}` - } catch { - return String(id) - } - } - private tableFromRecordId(id: RecordId | StringRecordId): string | undefined { - try { - if (typeof id === 'string') return id.split(':')[0] - return (id as any).tb ?? undefined - } catch { - return undefined - } + + private tableFromRecordId(recordId: RecordId | StringRecordId): string | undefined { + return (recordId as any).tb ?? undefined } private tableKey(table: string): string { return `table:${table}` } - private recordKey(id: RecordId | StringRecordId): string { - return `record:${this.recordIdToString(id)}` + private recordKey(recordId: RecordId | StringRecordId): string { + return `record:${recordId}` } private stableStringify(obj: unknown): string { From 5816559ecefc0970f9a5934913ddba181c7e8519 Mon Sep 17 00:00:00 2001 From: "Strittmatter, Stephan" Date: Thu, 13 Nov 2025 09:05:13 +0100 Subject: [PATCH 3/5] feat: add IndexedDB persistence DataCacheService and robust id handling Co-authored-by: aider (openai/gpt-5) --- src/app/services/data-cache.service.ts | 201 +++++++++++++++++++++---- src/app/services/surrealdb.service.ts | 28 +++- 2 files changed, 200 insertions(+), 29 deletions(-) diff --git a/src/app/services/data-cache.service.ts b/src/app/services/data-cache.service.ts index cb40bd70..bb38663e 100644 --- a/src/app/services/data-cache.service.ts +++ b/src/app/services/data-cache.service.ts @@ -7,76 +7,98 @@ type CacheEntry = { @Injectable({ providedIn: 'root' }) export class DataCacheService { + // In-memory store + in-flight dedup private readonly store = new Map>() private readonly inFlight = new Map>() + // Optional persistence + private readonly usePersistence = typeof indexedDB !== 'undefined' + private dbPromise: Promise | null = null + private readonly dbName = 'app-cache' + private readonly storeName = 'kv' + + // ---- Public API ---- + 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 + const entry = this.getFromMemory(key) + return entry ?? undefined } set(key: string, value: T, ttlMs: number): void { - this.store.set(key, { value, expiresAt: Date.now() + ttlMs }) + const expiresAt = Date.now() + ttlMs + this.setInMemory(key, { value, expiresAt }) + void this.setInIdb(key, { value, expiresAt }) } async getOrFetch(key: string, fetcher: () => Promise, ttlMs: number, swrMs = 0): Promise { const now = Date.now() - const cached = this.store.get(key) as CacheEntry | undefined - // Fresh cache - if (cached && cached.expiresAt > now) { - return cached.value + // 1) In-Memory (fresh) + const mem = this.getFromMemory(key) + if (mem && mem.expiresAt > now) { + return mem.value + } + + // 2) IndexedDB (fresh) + const disk = await this.getFromIdb(key) + if (disk && disk.expiresAt > now) { + this.setInMemory(key, disk) + return disk.value } - // SWR: return stale immediately and refresh in background - if (cached && swrMs > 0 && cached.expiresAt <= now && cached.expiresAt + swrMs > now) { + // 3) SWR (stale but within window) from memory or disk + const stale = mem ?? disk + if (stale && swrMs > 0 && stale.expiresAt <= now && stale.expiresAt + swrMs > now) { + // Background refresh if not already running if (!this.inFlight.has(key)) { const bg = fetcher() .then((value) => { - this.store.set(key, { value, expiresAt: Date.now() + ttlMs }) - this.inFlight.delete(key) - return value as unknown as T + const entry: CacheEntry = { value, expiresAt: Date.now() + ttlMs } + this.setInMemory(key, entry) + return this.setInIdb(key, entry).finally(() => { + this.inFlight.delete(key) + }) }) .catch(() => { this.inFlight.delete(key) - return cached.value - }) + }) as unknown as Promise + this.inFlight.set(key, bg) } - return cached.value + return stale.value } - // Deduplicate concurrent fetches + // 4) Deduplicate concurrent fetches if (this.inFlight.has(key)) { return (this.inFlight.get(key) as Promise)! } + // 5) Fetch and persist const p = fetcher() .then((value) => { - this.store.set(key, { value, expiresAt: Date.now() + ttlMs }) - this.inFlight.delete(key) - return value + const entry: CacheEntry = { value, expiresAt: Date.now() + ttlMs } + this.setInMemory(key, entry) + return this.setInIdb(key, entry).then(() => value) }) .catch((err) => { - this.inFlight.delete(key) throw err }) + .finally(() => { + this.inFlight.delete(key) + }) - this.inFlight.set(key, p as Promise) + this.inFlight.set(key, p as unknown as Promise) return p } invalidate(key: string): void { this.store.delete(key) this.inFlight.delete(key) + void this.deleteFromIdb(key) } invalidatePrefix(prefix: string): void { + // Memory for (const key of Array.from(this.store.keys())) { if (key.startsWith(prefix)) { this.store.delete(key) @@ -87,10 +109,137 @@ export class DataCacheService { this.inFlight.delete(key) } } + // Disk + void this.deletePrefixFromIdb(prefix) } clear(): void { this.store.clear() this.inFlight.clear() + void this.clearIdb() + } + + // ---- Memory helpers ---- + + private getFromMemory(key: string): CacheEntry | 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 + } + + private setInMemory(key: string, entry: CacheEntry): void { + this.store.set(key, entry as CacheEntry) + } + + // ---- IndexedDB helpers ---- + + private async openDb(): Promise { + if (!this.usePersistence) return null + if (this.dbPromise) { + try { + return await this.dbPromise + } catch { + this.dbPromise = null + } + } + this.dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(this.dbName, 1) + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName) + } + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) + try { + return await this.dbPromise + } catch { + this.dbPromise = null + return null + } + } + + private async getFromIdb(key: string): Promise | undefined> { + const db = await this.openDb() + if (!db) return undefined + return await new Promise | undefined>((resolve) => { + const tx = db.transaction(this.storeName, 'readonly') + const store = tx.objectStore(this.storeName) + const req = store.get(key) + req.onsuccess = () => { + const entry = req.result as CacheEntry | undefined + if (!entry) return resolve(undefined) + if (entry.expiresAt <= Date.now()) { + // Delete stale on read + const dtx = db.transaction(this.storeName, 'readwrite') + dtx.objectStore(this.storeName).delete(key) + resolve(undefined) + } else { + resolve(entry) + } + } + req.onerror = () => resolve(undefined) + }) + } + + private async setInIdb(key: string, entry: CacheEntry): Promise { + const db = await this.openDb() + if (!db) return + await new Promise((resolve) => { + const tx = db.transaction(this.storeName, 'readwrite') + tx.objectStore(this.storeName).put(entry, key) + tx.oncomplete = () => resolve() + tx.onerror = () => resolve() + }) + } + + private async deleteFromIdb(key: string): Promise { + const db = await this.openDb() + if (!db) return + await new Promise((resolve) => { + const tx = db.transaction(this.storeName, 'readwrite') + tx.objectStore(this.storeName).delete(key) + tx.oncomplete = () => resolve() + tx.onerror = () => resolve() + }) + } + + private async deletePrefixFromIdb(prefix: string): Promise { + const db = await this.openDb() + if (!db) return + await new Promise((resolve) => { + const tx = db.transaction(this.storeName, 'readwrite') + const store = tx.objectStore(this.storeName) + const req = store.openCursor() + req.onsuccess = () => { + const cursor = req.result + if (cursor) { + const key = String(cursor.key) + if (key.startsWith(prefix)) { + cursor.delete() + } + cursor.continue() + } + } + tx.oncomplete = () => resolve() + tx.onerror = () => resolve() + }) + } + + private async clearIdb(): Promise { + const db = await this.openDb() + if (!db) return + await new Promise((resolve) => { + const tx = db.transaction(this.storeName, 'readwrite') + tx.objectStore(this.storeName).clear() + tx.oncomplete = () => resolve() + tx.onerror = () => resolve() + }) } } diff --git a/src/app/services/surrealdb.service.ts b/src/app/services/surrealdb.service.ts index a0b7927c..9f32c3f8 100644 --- a/src/app/services/surrealdb.service.ts +++ b/src/app/services/surrealdb.service.ts @@ -20,10 +20,30 @@ export class SurrealdbService extends Surreal { super() } - + // Nutze eine robuste String-Repräsentation für Record-IDs + private recordIdToString(recordId: RecordId | StringRecordId): string { + try { + const s = (recordId as any)?.toString?.() + if (typeof s === 'string') return s + const tb = (recordId as any)?.tb + const id = (recordId as any)?.id + if (tb && id) return `${tb}:${id}` + return String(recordId) + } catch { + return String(recordId) + } + } private tableFromRecordId(recordId: RecordId | StringRecordId): string | undefined { - return (recordId as any).tb ?? undefined + try { + const tb = (recordId as any)?.tb + if (tb) return tb as string + const s = (recordId as any)?.toString?.() ?? String(recordId) + const idx = s.indexOf(':') + return idx > -1 ? s.slice(0, idx) : undefined + } catch { + return undefined + } } private tableKey(table: string): string { @@ -31,7 +51,7 @@ export class SurrealdbService extends Surreal { } private recordKey(recordId: RecordId | StringRecordId): string { - return `record:${recordId}` + return `record:${this.recordIdToString(recordId)}` } private stableStringify(obj: unknown): string { @@ -91,6 +111,8 @@ export class SurrealdbService extends Surreal { password: password, }, }) + // User-Kontext kann sich ändern -> Cache leeren, um falsche Daten zu vermeiden + this.cache.clear() return jwtToken } From 3b0285e1a871b57c7503ff89fbfb07f446eaac7a Mon Sep 17 00:00:00 2001 From: "Strittmatter, Stephan" Date: Thu, 13 Nov 2025 09:07:23 +0100 Subject: [PATCH 4/5] fix: return value from cache entry in DataCacheService.get Co-authored-by: aider (openai/gpt-5) --- src/app/services/data-cache.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/data-cache.service.ts b/src/app/services/data-cache.service.ts index bb38663e..b2e8ed97 100644 --- a/src/app/services/data-cache.service.ts +++ b/src/app/services/data-cache.service.ts @@ -21,7 +21,7 @@ export class DataCacheService { get(key: string): T | undefined { const entry = this.getFromMemory(key) - return entry ?? undefined + return entry ? entry.value : undefined } set(key: string, value: T, ttlMs: number): void { From 6321e59b2d3c27d42d1baa892bb7aa99d59a1a4d Mon Sep 17 00:00:00 2001 From: "Strittmatter, Stephan" Date: Thu, 20 Nov 2025 11:06:35 +0100 Subject: [PATCH 5/5] refactor: only in mem cache, no indexedDB any more --- src/app/services/data-cache.service.ts | 255 ++++++------------------- src/app/services/surrealdb.service.ts | 156 ++++++++------- 2 files changed, 147 insertions(+), 264 deletions(-) diff --git a/src/app/services/data-cache.service.ts b/src/app/services/data-cache.service.ts index b2e8ed97..3dad4984 100644 --- a/src/app/services/data-cache.service.ts +++ b/src/app/services/data-cache.service.ts @@ -7,239 +7,110 @@ type CacheEntry = { @Injectable({ providedIn: 'root' }) export class DataCacheService { - // In-memory store + in-flight dedup +/** + * 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>() - // Optional persistence - private readonly usePersistence = typeof indexedDB !== 'undefined' - private dbPromise: Promise | null = null - private readonly dbName = 'app-cache' - private readonly storeName = 'kv' - - // ---- Public API ---- - + /** + * Returns the cached value if present and still valid. + * Expired entries are evicted eagerly. + */ get(key: string): T | undefined { - const entry = this.getFromMemory(key) - return entry ? entry.value : 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.setInMemory(key, { value, expiresAt }) - void this.setInIdb(key, { value, expiresAt }) + this.store.set(key, { value, expiresAt }) } - async getOrFetch(key: string, fetcher: () => Promise, ttlMs: number, swrMs = 0): Promise { - const now = Date.now() - - // 1) In-Memory (fresh) - const mem = this.getFromMemory(key) - if (mem && mem.expiresAt > now) { - return mem.value - } - - // 2) IndexedDB (fresh) - const disk = await this.getFromIdb(key) - if (disk && disk.expiresAt > now) { - this.setInMemory(key, disk) - return disk.value + /** + * 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 } - // 3) SWR (stale but within window) from memory or disk - const stale = mem ?? disk - if (stale && swrMs > 0 && stale.expiresAt <= now && stale.expiresAt + swrMs > now) { - // Background refresh if not already running - if (!this.inFlight.has(key)) { - const bg = fetcher() - .then((value) => { - const entry: CacheEntry = { value, expiresAt: Date.now() + ttlMs } - this.setInMemory(key, entry) - return this.setInIdb(key, entry).finally(() => { - this.inFlight.delete(key) - }) - }) - .catch(() => { - this.inFlight.delete(key) - }) as unknown as Promise - - this.inFlight.set(key, bg) - } - return stale.value - } - - // 4) Deduplicate concurrent fetches if (this.inFlight.has(key)) { return (this.inFlight.get(key) as Promise)! } - // 5) Fetch and persist - const p = fetcher() - .then((value) => { - const entry: CacheEntry = { value, expiresAt: Date.now() + ttlMs } - this.setInMemory(key, entry) - return this.setInIdb(key, entry).then(() => value) - }) - .catch((err) => { - throw err + const request = fetcher() + .then((result) => { + this.set(key, result, ttlMs) + return result }) .finally(() => { this.inFlight.delete(key) }) - this.inFlight.set(key, p as unknown as Promise) - return p + 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) - void this.deleteFromIdb(key) } + /** Bulk invalidation helper used when tables or generic queries change. */ invalidatePrefix(prefix: string): void { - // Memory - for (const key of Array.from(this.store.keys())) { + for (const key of this.store.keys()) { if (key.startsWith(prefix)) { this.store.delete(key) } } - for (const key of Array.from(this.inFlight.keys())) { + for (const key of this.inFlight.keys()) { if (key.startsWith(prefix)) { this.inFlight.delete(key) } } - // Disk - void this.deletePrefixFromIdb(prefix) } + /** Clears the entire cache – typically used during logout. */ clear(): void { this.store.clear() this.inFlight.clear() - void this.clearIdb() - } - - // ---- Memory helpers ---- - - private getFromMemory(key: string): CacheEntry | 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 - } - - private setInMemory(key: string, entry: CacheEntry): void { - this.store.set(key, entry as CacheEntry) - } - - // ---- IndexedDB helpers ---- - - private async openDb(): Promise { - if (!this.usePersistence) return null - if (this.dbPromise) { - try { - return await this.dbPromise - } catch { - this.dbPromise = null - } - } - this.dbPromise = new Promise((resolve, reject) => { - const req = indexedDB.open(this.dbName, 1) - req.onupgradeneeded = () => { - const db = req.result - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName) - } - } - req.onsuccess = () => resolve(req.result) - req.onerror = () => reject(req.error) - }) - try { - return await this.dbPromise - } catch { - this.dbPromise = null - return null - } - } - - private async getFromIdb(key: string): Promise | undefined> { - const db = await this.openDb() - if (!db) return undefined - return await new Promise | undefined>((resolve) => { - const tx = db.transaction(this.storeName, 'readonly') - const store = tx.objectStore(this.storeName) - const req = store.get(key) - req.onsuccess = () => { - const entry = req.result as CacheEntry | undefined - if (!entry) return resolve(undefined) - if (entry.expiresAt <= Date.now()) { - // Delete stale on read - const dtx = db.transaction(this.storeName, 'readwrite') - dtx.objectStore(this.storeName).delete(key) - resolve(undefined) - } else { - resolve(entry) - } - } - req.onerror = () => resolve(undefined) - }) - } - - private async setInIdb(key: string, entry: CacheEntry): Promise { - const db = await this.openDb() - if (!db) return - await new Promise((resolve) => { - const tx = db.transaction(this.storeName, 'readwrite') - tx.objectStore(this.storeName).put(entry, key) - tx.oncomplete = () => resolve() - tx.onerror = () => resolve() - }) - } - - private async deleteFromIdb(key: string): Promise { - const db = await this.openDb() - if (!db) return - await new Promise((resolve) => { - const tx = db.transaction(this.storeName, 'readwrite') - tx.objectStore(this.storeName).delete(key) - tx.oncomplete = () => resolve() - tx.onerror = () => resolve() - }) - } - - private async deletePrefixFromIdb(prefix: string): Promise { - const db = await this.openDb() - if (!db) return - await new Promise((resolve) => { - const tx = db.transaction(this.storeName, 'readwrite') - const store = tx.objectStore(this.storeName) - const req = store.openCursor() - req.onsuccess = () => { - const cursor = req.result - if (cursor) { - const key = String(cursor.key) - if (key.startsWith(prefix)) { - cursor.delete() - } - cursor.continue() - } - } - tx.oncomplete = () => resolve() - tx.onerror = () => resolve() - }) - } - - private async clearIdb(): Promise { - const db = await this.openDb() - if (!db) return - await new Promise((resolve) => { - const tx = db.transaction(this.storeName, 'readwrite') - tx.objectStore(this.storeName).clear() - tx.oncomplete = () => resolve() - tx.onerror = () => resolve() - }) } } diff --git a/src/app/services/surrealdb.service.ts b/src/app/services/surrealdb.service.ts index 9f32c3f8..9f3dc4e0 100644 --- a/src/app/services/surrealdb.service.ts +++ b/src/app/services/surrealdb.service.ts @@ -1,49 +1,40 @@ 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 | null = null - - // Lightweight in-memory cache to reduce duplicate DB requests private readonly cache = inject(DataCacheService) - private readonly defaultTtlMs = 60_000 // 60s default cache for read operations - private readonly searchTtlMs = 10_000 // short cache for search results + private readonly router = inject(Router, { optional: true }) + private readonly defaultTtlMs = 60_000 + private readonly searchTtlMs = 10_000 constructor() { super() } - // Nutze eine robuste String-Repräsentation für Record-IDs private recordIdToString(recordId: RecordId | StringRecordId): string { - try { - const s = (recordId as any)?.toString?.() - if (typeof s === 'string') return s - const tb = (recordId as any)?.tb - const id = (recordId as any)?.id - if (tb && id) return `${tb}:${id}` - return String(recordId) - } catch { - return String(recordId) + const asString = (recordId as unknown as { toString?: () => string })?.toString?.() + if (typeof asString === 'string') { + return asString } - } - - private tableFromRecordId(recordId: RecordId | StringRecordId): string | undefined { - try { - const tb = (recordId as any)?.tb - if (tb) return tb as string - const s = (recordId as any)?.toString?.() ?? String(recordId) - const idx = s.indexOf(':') - return idx > -1 ? s.slice(0, idx) : undefined - } catch { - return undefined + 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 { @@ -54,20 +45,19 @@ export class SurrealdbService extends Surreal { return `record:${this.recordIdToString(recordId)}` } - private stableStringify(obj: unknown): string { - if (obj == null || typeof obj !== 'object') return JSON.stringify(obj) - const keys = new Set() - JSON.stringify(obj as any, (k, v) => { - keys.add(k) - return v - }) - const sorted = Array.from(keys).sort() - return JSON.stringify(obj as any, sorted) - } - - private queryKey(sql: string, params?: unknown): string { - const p = params === undefined ? '' : `:${this.stableStringify(params)}` - return `query:${sql}${p}` + 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() { @@ -111,7 +101,6 @@ export class SurrealdbService extends Surreal { password: password, }, }) - // User-Kontext kann sich ändern -> Cache leeren, um falsche Daten zu vermeiden this.cache.clear() return jwtToken } @@ -122,31 +111,29 @@ 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 key = this.recordKey(recordId) - return await this.cache.getOrFetch( - key, - async () => { - 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() - const key = this.tableKey(table) - return await this.cache.getOrFetch( - key, - async () => { - return await super.select(table) - }, + return await this.fetchCached( + this.tableKey(table), this.defaultTtlMs, + async () => await super.select(table), + options, ) } @@ -154,11 +141,10 @@ export class SurrealdbService extends Surreal { async post>(table: string, payload?: T | T[]): Promise { // Stelle sicher, dass die Verbindung initialisiert ist await this.initialize() - const res = await super.insert(table, payload) - // Invalidate caches related to this table and generic queries + const result = await super.insert(table, payload) this.cache.invalidate(this.tableKey(table)) this.cache.invalidatePrefix('query:') - return res + return result } async postUpdate>(id: RecordId | StringRecordId, payload?: T): Promise { @@ -167,10 +153,11 @@ export class SurrealdbService extends Surreal { await this.initialize() const updatedRecord = await super.update(id, payload) - // Invalidate caches for this record and its table - const table = this.tableFromRecordId(id) + const table = (id as { tb?: string })?.tb this.cache.invalidate(this.recordKey(id)) - if (table) this.cache.invalidate(this.tableKey(table)) + if (table) { + this.cache.invalidate(this.tableKey(table)) + } this.cache.invalidatePrefix('query:') return updatedRecord @@ -185,10 +172,11 @@ export class SurrealdbService extends Surreal { await this.initialize() await super.delete(recordId) - // Invalidate caches for this record and its table - const table = this.tableFromRecordId(recordId) + const table = (recordId as { tb?: string })?.tb this.cache.invalidate(this.recordKey(recordId)) - if (table) this.cache.invalidate(this.tableKey(table)) + if (table) { + this.cache.invalidate(this.tableKey(table)) + } this.cache.invalidatePrefix('query:') } @@ -226,22 +214,46 @@ export class SurrealdbService extends Surreal { ORDER BY relevance DESC LIMIT 30;` - const key = this.queryKey(ftsSql, { q }) - try { - const result = await this.cache.getOrFetch( - key, + const result = await this.fetchCached( + this.queryKey(ftsSql, { q }), + this.searchTtlMs, async () => { - const res = (await super.query(ftsSql, { 'q': q }))[0] as AppEvent[] - return Array.isArray(res) ? res : [] + const queryResult = (await super.query(ftsSql, { 'q': q }))[0] as AppEvent[] + return Array.isArray(queryResult) ? queryResult : [] }, - this.searchTtlMs, - this.searchTtlMs, // allow brief SWR window to throttle rapid typing ) 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 } }