Öffnungszeiten: Speicher mit Versionen und Cache

LRU-Speicher mit Ablaufzeit (lru.js), Cache-Instanz der öffentlichen Antwort
(cache.js: 500 Einträge, 30 s) und Appwrite-Speicher mit getrennten Versionen
für Kundendaten und Team-Einstellungen (speicher.js), alle Schreibzugriffe
unter der Sperre zeiten:<projectId>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 00:50:41 +00:00
1 parent 88fb9263f9
commit 097036c9a5
5 files changed
+1034

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
import { neuerLru } from './lru.js'
/**
* Speicher der oeffentlichen Zeiten-Antwort (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 4: 30 Sekunden,
* als LRU hoechstens 500 Eintraege). Schluessel = siteKey. Wer Zeiten oder Team-Einstellungen schreibt, ruft vergissZeiten;
* wer eine Antwort baut, legt sie mit zeitenCache.setze(siteKey, antwort, jetztMs) ab und holt sie mit hole (undefined = nicht da).
* Ein Eintrag verfaellt 30 Sekunden nach dem Setzen, auch ohne Speichern (Zustandswechsel der Funktion wirken so mit hoechstens 30 s Verzoegerung).
*/
export const zeitenCache = neuerLru({ max: 500, ttlMs: 30000 })
/** Entfernt die Antwort dieses siteKeys (nach jedem Schreiben). Unbekannte oder fehlende Schluessel schaden nicht. */
export function vergissZeiten(siteKey) {
zeitenCache.vergiss(siteKey)
}
export function resetZeitenCacheForTests() {
zeitenCache.leere()
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Oeffnungszeiten live: kleiner LRU-Speicher mit Ablaufzeit (rein, ohne eigene Uhr: die Zeit kommt als Millisekunden-Parameter).
*
* - hoechstens max Eintraege; ist er voll, faellt der am laengsten nicht benutzte (hole oder setze) heraus;
* - jeder Eintrag verfaellt ttlMs nach dem Setzen (genau nach ttlMs ist er weg); ein Treffer erneuert nur seinen Platz in
* der Reihe, nie die Ablaufzeit, damit auch ein oft abgefragter Eintrag spaetestens nach ttlMs neu gebaut wird;
* - hole liefert bei Fehlschlag oder Ablauf undefined; gespeichert werden darf jeder Wert (auch null, 0, '').
* Umsetzung: eine Map; ihre Einfuegereihenfolge ist die Nutzungsreihenfolge (aeltester zuerst), ein Treffer haengt den Eintrag neu an.
*/
const pruefeZeit = (nowMs, wer) => {
if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) throw new TypeError(`${wer}: nowMs muss eine Zahl (Millisekunden) sein`)
}
/**
* @param {{ max: number, ttlMs: number }} optionen max = ganze Zahl >= 1, ttlMs = Zahl > 0 (endlich)
* @returns {{ hole(key: string, nowMs: number): *, setze(key: string, wert: *, nowMs: number): void, vergiss(key: string): void, leere(): void, groesse(): number }}
*/
export function neuerLru({ max, ttlMs } = {}) {
if (!Number.isInteger(max) || max < 1) throw new TypeError('neuerLru: max muss eine ganze Zahl ab 1 sein')
if (typeof ttlMs !== 'number' || !Number.isFinite(ttlMs) || ttlMs <= 0) throw new TypeError('neuerLru: ttlMs muss eine Zahl groesser 0 sein')
/** @type {Map<string, { bis: number, wert: * }>} */
const eintraege = new Map()
return {
hole(key, nowMs) {
pruefeZeit(nowMs, 'hole')
const eintrag = eintraege.get(key)
if (!eintrag) return undefined
eintraege.delete(key)
if (eintrag.bis <= nowMs) return undefined
eintraege.set(key, eintrag)
return eintrag.wert
},
setze(key, wert, nowMs) {
pruefeZeit(nowMs, 'setze')
eintraege.delete(key)
eintraege.set(key, { bis: nowMs + ttlMs, wert })
while (eintraege.size > max) eintraege.delete(eintraege.keys().next().value)
},
vergiss(key) {
eintraege.delete(key)
},
leere() {
eintraege.clear()
},
groesse() {
return eintraege.size
},
}
}
+284
View File
@@ -0,0 +1,284 @@
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { vergissZeiten, zeitenCache } from './cache.js'
import { normalisiereTeam, normalisiereZeiten } from './pruefen.js'
/**
* Oeffnungszeiten live: Speicher in Appwrite (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 2.1).
* Ein Dokument je Funktion in config.collections.oeffnungszeiten, Dokument-ID = ID des portalFeatures-Dokuments (feature.$id).
*
* Zwei getrennte Versionen (optimistische Sperre): `version` zaehlt die Speichervorgaenge des Kunden (daten), `teamVersion` die
* des Teams (team); ein Kunde im Editor und das Team in WOMS stoeren sich so nicht. 0 = noch nie gespeichert. Speichert das Team,
* bevor der Kunde Zeiten eingetragen hat, entsteht das Dokument mit version 0 und ohne daten (ladeZeiten: daten null); der
* Kunde speichert danach mit version 0 und bekommt 1.
* Weicht die mitgebrachte Version von der gespeicherten ab (auch wenn sie fehlt oder keine ganze Zahl ist), passiert nichts und
* es folgt { status: 409, code: 'ZEITEN_VERALTET' }; ungueltige Eingaben ergeben { status: 400, fehler }.
*
* Alle Schreibzugriffe (Zeiten, Team, Ersatz-Hash, Loeschen) laufen unter der Sperre `zeiten:<projectId>` (nicht wiederbetretbar:
* Aufrufer duerfen sie nicht selbst halten); gelesen und verglichen wird INNERHALB der Sperre. Nach jedem Schreibversuch (auch
* einem fehlgeschlagenen: unklar, was in der Datenbank steht) verwirft vergissZeiten die oeffentliche Antwort dieses siteKeys.
*/
const veraltet = () =>
Object.assign(new Error('Inzwischen geändert – bitte neu laden.'), { status: 409, code: 'ZEITEN_VERALTET' })
const ungueltig = (nachricht, fehler) => Object.assign(new Error(nachricht), { status: 400, fehler })
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function pruefeText(wert, name) {
if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`)
}
function pruefeFeature(feature) {
pruefeText(feature?.$id, 'feature.$id')
pruefeText(feature.projectId, 'feature.projectId')
}
function pruefeJetzt(now) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('now ist kein gueltiges Datum')
return now
}
/** updatedBy: 'kunde' oder 'team:<staffId>' (Spalte 64 Zeichen). */
const vonWem = (by, standard) => String(by ?? '').trim().slice(0, 64) || standard
/** Sperre aller Schreibzugriffe auf die Zeiten eines Projekts. */
const sperre = (projectId) => `zeiten:${projectId}`
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt, das ist ein Fehler und wird weitergereicht). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
async function holeOderNull(collectionId, id) {
try {
return await getDocument(collectionId, id)
} catch (err) {
if (dokumentFehlt(err)) return null
throw err
}
}
/** Dokument loeschen; war es schon weg, ist das kein Fehler. => true, wenn dieser Aufruf es geloescht hat. */
async function loescheOderIgnoriere(collectionId, id) {
try {
await deleteDocument(collectionId, id)
return true
} catch (err) {
if (dokumentFehlt(err)) return false
throw err
}
}
const versionVon = (doc, feld) => (doc && Number.isInteger(doc[feld]) ? doc[feld] : 0)
/** JSON-Objekt aus einem Textfeld; fehlt es: null (still), unlesbar: null mit einer Logzeile ohne Inhalt (der gehoert nicht ins Log). */
function liesObjekt(doc, feld) {
const roh = doc?.[feld]
if (typeof roh !== 'string' || roh === '') return null
try {
const wert = JSON.parse(roh)
if (istObjekt(wert)) return wert
} catch {
// unten gemeldet
}
console.error(`[zeiten-speicher] ${feld} von ${doc.$id} ist unlesbar`)
return null
}
const standardTeam = () => normalisiereTeam({}).team
/** Team-Einstellungen aus dem Dokument; fehlend, unlesbar oder nicht mehr gueltig (von Hand veraendert) ergibt die Vorgabe. */
function liesTeam(doc) {
const roh = liesObjekt(doc, 'team')
if (!roh) return standardTeam()
const erg = normalisiereTeam(roh)
if (erg.fehler) {
console.error(`[zeiten-speicher] team von ${doc.$id} ist ungueltig`)
return standardTeam()
}
return erg.team
}
function grundfelder(feature) {
return { customerId: feature.customerId ?? '', projectId: feature.projectId, featureId: feature.$id }
}
/**
* Schreibt felder in das Dokument der Funktion (aendern; gibt es keins, mit den Grundfeldern anlegen, beide Versionen 0, soweit
* felder sie nicht setzt). Legt ein anderer Vorgang das Dokument zwischen Lesen und Anlegen an (Appwrite 409), ist der Stand veraltet.
* Danach (auch bei Fehlern) ist die oeffentliche Antwort dieses siteKeys vergessen.
*/
async function schreibe(feature, vorhanden, felder) {
const col = config.collections.oeffnungszeiten
try {
if (vorhanden) {
await updateDocument(col, feature.$id, felder)
return
}
try {
await createDocument(col, { ...grundfelder(feature), version: 0, teamVersion: 0, ...felder }, feature.$id)
} catch (err) {
if (err?.status === 409) throw veraltet()
throw err
}
} finally {
vergissZeiten(feature.siteKey)
}
}
/**
* Die gespeicherten Zeiten einer Funktion.
* @param {string} featureId ID des portalFeatures-Dokuments (= ID des oeffnungszeiten-Dokuments)
* @returns {Promise<{ daten: object|null, version: number, team: object, teamVersion: number, ersatzHash: string }|null>}
* null, wenn es kein Dokument gibt. daten ist null, wenn der Kunde noch nichts gespeichert hat (nur Team-Einstellungen oder
* Ersatz-Hash vorhanden) oder die Daten unlesbar sind (version bleibt die des Dokuments: wer sie ueberschreiben will, speichert
* mit genau dieser Version). team ist immer vollstaendig (Vorgabe, wenn nichts oder nichts Gueltiges gespeichert ist),
* ersatzHash '' ohne Eintrag.
*/
export async function ladeZeiten(featureId) {
pruefeText(featureId, 'featureId')
const doc = await holeOderNull(config.collections.oeffnungszeiten, featureId)
if (!doc) return null
return {
daten: liesObjekt(doc, 'daten'),
version: versionVon(doc, 'version'),
team: liesTeam(doc),
teamVersion: versionVon(doc, 'teamVersion'),
ersatzHash: typeof doc.ersatzHash === 'string' ? doc.ersatzHash : '',
}
}
/**
* Speichert die Zeiten des Kunden (oder des Teams im Namen des Kunden). Pruefen (normalisiereZeiten, mit den gespeicherten Daten als
* bisher fuer die unveraenderlichen Kurznamen) und Schreiben laufen unter der Sperre, nach der Versionspruefung: ist die Version
* veraltet, gewinnt 409, auch bei falscher Eingabe.
* @param {{ feature: { $id: string, customerId: string, projectId: string, siteKey?: string },
* eingabe: object, version: number, by?: string, now?: Date }} p
* version = Version, auf der die Aenderung beruht (0 = noch nie gespeichert); by = 'kunde' (Vorgabe) | 'team:<staffId>'
* @returns {Promise<{ daten: object, version: number }>} das Normalisierte, wie gespeichert, mit der neuen Version
* @throws {Error} status 400 mit fehler (je Pfad eine Meldung) / status 409 mit code 'ZEITEN_VERALTET'
*/
export async function speichereZeiten({ feature, eingabe, version, by = 'kunde', now = new Date() } = {}) {
pruefeFeature(feature)
const jetzt = pruefeJetzt(now)
return withLocks([sperre(feature.projectId)], async () => {
const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id)
const aktuell = versionVon(vorhanden, 'version')
if (version !== aktuell) throw veraltet()
const erg = normalisiereZeiten(eingabe, { bisher: vorhanden ? liesObjekt(vorhanden, 'daten') : null, now: jetzt })
if (erg.fehler) throw ungueltig('Bitte prüfe deine Eingaben.', erg.fehler)
await schreibe(feature, vorhanden, {
daten: JSON.stringify(erg.daten),
version: aktuell + 1,
updatedAt: jetzt.toISOString(),
updatedBy: vonWem(by, 'kunde'),
})
return { daten: erg.daten, version: aktuell + 1 }
})
}
/**
* Speichert die Team-Einstellungen (nur ueber WOMS); eigene Version teamVersion, die Kundendaten und deren Version bleiben.
* Gibt es noch kein Dokument, wird eins ohne daten angelegt (version 0).
* @param {{ feature: object, eingabe: object, teamVersion: number, by?: string, now?: Date }} p by = 'team:<staffId>'
* @returns {Promise<{ team: object, teamVersion: number }>}
* @throws {Error} status 400 mit fehler / status 409 mit code 'ZEITEN_VERALTET'
*/
export async function speichereTeam({ feature, eingabe, teamVersion, by = 'team', now = new Date() } = {}) {
pruefeFeature(feature)
const jetzt = pruefeJetzt(now)
return withLocks([sperre(feature.projectId)], async () => {
const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id)
const aktuell = versionVon(vorhanden, 'teamVersion')
if (teamVersion !== aktuell) throw veraltet()
const erg = normalisiereTeam(eingabe)
if (erg.fehler) throw ungueltig('Bitte prüfe die Team-Einstellungen.', erg.fehler)
await schreibe(feature, vorhanden, {
team: JSON.stringify(erg.team),
teamVersion: aktuell + 1,
updatedAt: jetzt.toISOString(),
updatedBy: vonWem(by, 'team'),
})
return { team: erg.team, teamVersion: aktuell + 1 }
})
}
/**
* Merkt den Hash des Hauptplan-Wochenplans zum Zeitpunkt des Ersatz-HTMLs (wochenplanHash, '' = zuruecksetzen). Fasst weder
* die Versionen noch updatedAt/updatedBy noch Daten und Team an: kein Kunde und kein Team-Editor wird dadurch veraltet.
* Gibt es noch kein Dokument, entsteht eins ohne daten (version 0).
*/
export async function setzeErsatzHash(feature, hash) {
pruefeFeature(feature)
if (typeof hash !== 'string' || hash.length > 64) throw new TypeError('hash muss ein Text mit hoechstens 64 Zeichen sein')
await withLocks([sperre(feature.projectId)], async () => {
const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id)
await schreibe(feature, vorhanden, vorhanden ? { ersatzHash: hash } : { ersatzHash: hash, updatedAt: new Date().toISOString(), updatedBy: 'system' })
})
}
/** Loescht alle Erinnerungsmerker einer Funktion (mehrere Seiten moeglich). */
async function loescheMerker(featureId) {
const merker = await listAllDocuments(config.collections.zeitenErinnerungen, [Query.equal('featureId', featureId), Query.select(['featureId'])], { pageSize: 100 })
for (const doc of merker) await loescheOderIgnoriere(config.collections.zeitenErinnerungen, doc.$id)
}
/** Loescht Dokument (unter der Sperre seines Projekts) und Merker einer Funktion. => true, wenn dieser Aufruf das Dokument geloescht hat. */
async function loescheFunktion(featureId, projectId) {
const dokumentGeloescht = await withLocks([sperre(projectId)], () => loescheOderIgnoriere(config.collections.oeffnungszeiten, featureId))
await loescheMerker(featureId)
return dokumentGeloescht
}
/**
* Loescht die Zeiten einer Funktion samt Erinnerungsmerkern (Abo-Ende nach der Frist). Ein fehlendes Dokument ist kein Fehler.
* Die oeffentliche Antwort wird verworfen (der siteKey ist hier nicht bekannt: der ganze Speicher wird geleert, Loeschen ist selten).
* @returns {Promise<boolean>} true, wenn es ein Dokument gab und es geloescht wurde
*/
export async function loescheZeiten(featureId) {
pruefeText(featureId, 'featureId')
try {
const doc = await holeOderNull(config.collections.oeffnungszeiten, featureId)
if (!doc) {
await loescheMerker(featureId)
return false
}
return await loescheFunktion(featureId, doc.projectId)
} finally {
zeitenCache.leere()
}
}
/**
* Loescht alle Zeiten-Dokumente eines Kunden samt Erinnerungsmerkern (Kunden-Loeschen). Jedes Dokument unter der Sperre seines
* Projekts; ein Fehler bricht nicht ab: alle uebrigen werden trotzdem geloescht und der erste Fehler kommt am Ende (ein neuer
* Aufruf raeumt den Rest).
* @returns {Promise<number>} Anzahl der geloeschten Dokumente
*/
export async function loescheZeitenVonKunde(customerId) {
pruefeText(customerId, 'customerId')
let geloescht = 0
let ersterFehler = null
try {
const docs = await listAllDocuments(config.collections.oeffnungszeiten, [Query.equal('customerId', customerId), Query.select(['projectId'])], { pageSize: 100 })
for (const doc of docs) {
try {
if (await loescheFunktion(doc.$id, doc.projectId)) geloescht += 1
} catch (err) {
ersterFehler ??= err
}
}
} finally {
zeitenCache.leere()
}
if (ersterFehler) throw ersterFehler
return geloescht
}