feat(robustheit): optimistische Nebenläufigkeit (featuresVersion + Konflikt-Erkennung)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28m41s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28m41s
Verhindert stilles Überschreiben neuerer Änderungen durch veralteten Stand (z.B. nach Offline-Reconnect oder Lock-Ablauf bei Multi-Device). - Project.featuresVersion (Schema + Auto-Migration), pro Features-Speichern +1 - Features-PUT: optimistischer Lock in interaktiver Transaktion (updateMany WHERE featuresVersion=baseVersion -> atomar; count=0 => 409 Konflikt) - Bei Konflikt liefert der Server den aktuellen Stand zurück (409 + features + version) - Client sendet baseVersion (Auto-Save, manuell, Beacon, Offline-Queue), übernimmt bei 409 den Server-Stand und warnt den Nutzer (statt still zu überschreiben) - Offline-Queue verwirft konfligierende 4xx (kein Clobber beim Reconnect) - Transaktions-Timeout 15s für grosse Zeichnungen auf langsamer DB Version 1.4.11 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,8 +58,10 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
||||
- [x] localStorage-Features pro Projekt (`lageplan-features-<id>`) statt globalem Key
|
||||
- [x] Sync-Queue verwirft dauerhafte 4xx-Fehler (kein Endlos-Retry/verstopfte Queue)
|
||||
- [x] `beforeunload` persistiert auch leere Listen (gelöschte Elemente gehen nicht verloren)
|
||||
- Offen (🟡 5): Multi-Device-Versionierung — Editor-Lock ist pro Session, nicht pro User;
|
||||
Last-Writer-Wins ohne `updatedAt`-Check. Eigener grösserer Schritt.
|
||||
- [x] **5: Optimistische Nebenläufigkeit** — `Project.featuresVersion` + Versions-Check
|
||||
beim Speichern. Ein veralteter (z.B. offline) Stand überschreibt neuere Änderungen nicht
|
||||
mehr still, sondern erzeugt 409 → Client übernimmt Server-Stand + Warnung. Offline-Queue
|
||||
verwirft konfliktierende 4xx. (Merge pro Element / echtes Co-Drawing weiterhin offen.)
|
||||
|
||||
## Zusatz – Leitungsrechner professionalisiert ✅
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.4.10",
|
||||
"version": "1.4.11",
|
||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -73,6 +73,7 @@ async function migrate() {
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "exerciseEvaluation" TEXT`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "featuresVersion" INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`,
|
||||
|
||||
@@ -166,6 +166,10 @@ model Project {
|
||||
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
|
||||
mapZoom Float @default(15)
|
||||
isLocked Boolean @default(false)
|
||||
// Optimistische Nebenläufigkeit: wird bei jedem Features-Speichern hochgezählt.
|
||||
// Verhindert, dass ein veralteter (z.B. offline zwischengespeicherter) Stand
|
||||
// neuere Änderungen stillschweigend überschreibt.
|
||||
featuresVersion Int @default(0)
|
||||
|
||||
// Live editing lock (session-based for same-account multi-device)
|
||||
editingById String?
|
||||
|
||||
@@ -20,12 +20,15 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const features = await (prisma as any).feature.findMany({
|
||||
where: { projectId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
const [features, proj] = await Promise.all([
|
||||
(prisma as any).feature.findMany({
|
||||
where: { projectId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
(prisma as any).project.findUnique({ where: { id }, select: { featuresVersion: true } }),
|
||||
])
|
||||
|
||||
return NextResponse.json({ features })
|
||||
return NextResponse.json({ features, featuresVersion: proj?.featuresVersion ?? 0 })
|
||||
} catch (error) {
|
||||
console.error('Error fetching features:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
@@ -113,44 +116,74 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { features, mapCenter, mapZoom } = body as {
|
||||
const { features, mapCenter, mapZoom, baseVersion } = body as {
|
||||
features: Array<{ id?: string; type: string; geometry: object; properties?: object }>
|
||||
mapCenter?: { lng: number; lat: number }
|
||||
mapZoom?: number
|
||||
baseVersion?: number
|
||||
}
|
||||
|
||||
// Persist map viewport alongside features (if provided)
|
||||
if (mapCenter && mapZoom !== undefined) {
|
||||
await (prisma as any).project.update({
|
||||
where: { id },
|
||||
data: { mapCenter, mapZoom },
|
||||
})
|
||||
try {
|
||||
// ATOMAR + optimistischer Lock in EINER interaktiven Transaktion:
|
||||
// - Version prüfen/hochzählen (verhindert Überschreiben durch veralteten Stand)
|
||||
// - Features löschen + neu anlegen (kein Totalverlust-Fenster)
|
||||
const result = await (prisma as any).$transaction(async (tx: any) => {
|
||||
if (typeof baseVersion === 'number') {
|
||||
// Nur speichern, wenn die Version noch stimmt (atomar über WHERE-Bedingung)
|
||||
const bump = await tx.project.updateMany({
|
||||
where: { id, featuresVersion: baseVersion },
|
||||
data: { featuresVersion: { increment: 1 } },
|
||||
})
|
||||
if (bump.count === 0) {
|
||||
const conflict: any = new Error('VERSION_CONFLICT')
|
||||
conflict.code = 'VERSION_CONFLICT'
|
||||
throw conflict
|
||||
}
|
||||
} else {
|
||||
// Kein baseVersion mitgeschickt (Alt-Client/Beacon) → nur hochzählen
|
||||
await tx.project.update({ where: { id }, data: { featuresVersion: { increment: 1 } } })
|
||||
}
|
||||
|
||||
if (mapCenter && mapZoom !== undefined) {
|
||||
await tx.project.update({ where: { id }, data: { mapCenter, mapZoom } })
|
||||
}
|
||||
|
||||
await tx.feature.deleteMany({ where: { projectId: id } })
|
||||
if (features && features.length > 0) {
|
||||
await tx.feature.createMany({
|
||||
data: features.map((f: any) => ({
|
||||
projectId: id,
|
||||
type: f.type,
|
||||
geometry: f.geometry,
|
||||
properties: f.properties || {},
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
const [updatedFeatures, proj] = await Promise.all([
|
||||
tx.feature.findMany({ where: { projectId: id } }),
|
||||
tx.project.findUnique({ where: { id }, select: { featuresVersion: true } }),
|
||||
])
|
||||
return { features: updatedFeatures, featuresVersion: proj?.featuresVersion ?? 0 }
|
||||
}, { timeout: 15000, maxWait: 5000 })
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (txError: any) {
|
||||
if (txError?.code === 'VERSION_CONFLICT') {
|
||||
// Konflikt: aktuellen Server-Stand zurückgeben, damit der Client abgleichen kann
|
||||
const [currentFeatures, proj] = await Promise.all([
|
||||
(prisma as any).feature.findMany({ where: { projectId: id }, orderBy: { createdAt: 'asc' } }),
|
||||
(prisma as any).project.findUnique({ where: { id }, select: { featuresVersion: true } }),
|
||||
])
|
||||
return NextResponse.json({
|
||||
error: 'Der Einsatz wurde zwischenzeitlich geändert.',
|
||||
conflict: true,
|
||||
features: currentFeatures,
|
||||
featuresVersion: proj?.featuresVersion ?? 0,
|
||||
}, { status: 409 })
|
||||
}
|
||||
throw txError
|
||||
}
|
||||
|
||||
// ATOMAR: löschen + neu anlegen in EINER Transaktion.
|
||||
// Sonst droht bei Abbruch zwischen delete und create Totalverlust aller Elemente.
|
||||
const ops: any[] = [
|
||||
(prisma as any).feature.deleteMany({ where: { projectId: id } }),
|
||||
]
|
||||
if (features && features.length > 0) {
|
||||
ops.push(
|
||||
(prisma as any).feature.createMany({
|
||||
data: features.map((f: any) => ({
|
||||
projectId: id,
|
||||
type: f.type,
|
||||
geometry: f.geometry,
|
||||
properties: f.properties || {},
|
||||
})),
|
||||
})
|
||||
)
|
||||
}
|
||||
await (prisma as any).$transaction(ops)
|
||||
|
||||
const updatedFeatures = await (prisma as any).feature.findMany({
|
||||
where: { projectId: id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ features: updatedFeatures })
|
||||
} catch (error) {
|
||||
console.error('Error updating features:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
|
||||
@@ -72,6 +72,9 @@ export default function AppPage() {
|
||||
const featuresRef = useRef<DrawFeature[]>(features)
|
||||
useEffect(() => { featuresRef.current = features }, [features])
|
||||
|
||||
// Optimistischer Lock: Features-Version des aktuellen Projekts mitführen
|
||||
const featuresVersionRef = useRef<number>(0)
|
||||
|
||||
// Ref for undo-draw-point (removes last point during line drawing)
|
||||
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
|
||||
|
||||
@@ -385,6 +388,24 @@ export default function AppPage() {
|
||||
const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen
|
||||
const isReadOnly = !!editingBy && !isEditingByMe
|
||||
|
||||
// Features-Version bei Projektwechsel initialisieren
|
||||
useEffect(() => {
|
||||
featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0
|
||||
}, [currentProject?.id])
|
||||
|
||||
// Speicher-Konflikt: Server-Stand übernehmen und den Nutzer warnen
|
||||
const handleSaveConflict = useCallback((serverFeatures: DrawFeature[], serverVersion: number) => {
|
||||
setFeatures(serverFeatures)
|
||||
featuresVersionRef.current = serverVersion
|
||||
undoStackRef.current = []
|
||||
redoStackRef.current = []
|
||||
toast({
|
||||
title: 'Konflikt erkannt',
|
||||
description: 'Der Einsatz wurde von jemand anderem geändert — der aktuelle Stand wurde geladen. Bitte prüfe deine Änderungen und mache sie ggf. erneut.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}, [toast])
|
||||
|
||||
// Auto-save: localStorage persistence + debounced API save + beacon on unload
|
||||
useAutoSave({
|
||||
currentProject,
|
||||
@@ -394,6 +415,8 @@ export default function AppPage() {
|
||||
socketRef,
|
||||
isEditingByMe,
|
||||
setSyncQueueCount,
|
||||
featuresVersionRef,
|
||||
onConflict: handleSaveConflict,
|
||||
})
|
||||
|
||||
// Fullscreen toggle
|
||||
@@ -522,7 +545,7 @@ export default function AppPage() {
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const saveBody: any = { features }
|
||||
const saveBody: any = { features, baseVersion: featuresVersionRef.current }
|
||||
if (mapRef.current) {
|
||||
const c = mapRef.current.getCenter()
|
||||
saveBody.mapCenter = { lng: c.lng, lat: c.lat }
|
||||
@@ -554,19 +577,34 @@ export default function AppPage() {
|
||||
}
|
||||
const created = await createRes.json()
|
||||
setCurrentProject(created.project)
|
||||
featuresVersionRef.current = created.project?.featuresVersion ?? 0
|
||||
// Retry save with new project ID
|
||||
res = await fetch(`/api/projects/${created.project.id}/features`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ features }),
|
||||
body: JSON.stringify({ features, baseVersion: featuresVersionRef.current }),
|
||||
})
|
||||
}
|
||||
|
||||
// Versionskonflikt: Server-Stand übernehmen statt zu überschreiben
|
||||
if (res.status === 409) {
|
||||
const data = await res.json().catch(() => null)
|
||||
if (data?.conflict) {
|
||||
handleSaveConflict(data.features || [], data.featuresVersion ?? featuresVersionRef.current)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Speichern fehlgeschlagen')
|
||||
}
|
||||
|
||||
const okData = await res.json().catch(() => null)
|
||||
if (okData && typeof okData.featuresVersion === 'number') {
|
||||
featuresVersionRef.current = okData.featuresVersion
|
||||
}
|
||||
|
||||
// Broadcast features to other clients
|
||||
socketRef.current?.emit('features-updated', {
|
||||
projectId: currentProject.id,
|
||||
|
||||
@@ -10,6 +10,10 @@ interface UseAutoSaveOptions {
|
||||
socketRef: React.MutableRefObject<any>
|
||||
isEditingByMe: boolean
|
||||
setSyncQueueCount: (count: number) => void
|
||||
/** Aktuelle Features-Version des Projekts (optimistischer Lock) */
|
||||
featuresVersionRef: React.MutableRefObject<number>
|
||||
/** Wird bei einem Speicher-Konflikt (409) mit dem Server-Stand aufgerufen */
|
||||
onConflict: (serverFeatures: DrawFeature[], serverVersion: number) => void
|
||||
}
|
||||
|
||||
export function useAutoSave({
|
||||
@@ -20,6 +24,8 @@ export function useAutoSave({
|
||||
socketRef,
|
||||
isEditingByMe,
|
||||
setSyncQueueCount,
|
||||
featuresVersionRef,
|
||||
onConflict,
|
||||
}: UseAutoSaveOptions) {
|
||||
// Persist features to localStorage on change (per project — verhindert, dass Features
|
||||
// eines Projekts in ein anderes "bluten"). Inkl. leerem Array (spiegelt Löschungen).
|
||||
@@ -34,7 +40,7 @@ export function useAutoSave({
|
||||
if (!currentProject?.id) return
|
||||
const url = `/api/projects/${currentProject.id}/features`
|
||||
const mapInstance = mapRef.current
|
||||
const body: any = { features: featuresRef.current }
|
||||
const body: any = { features: featuresRef.current, baseVersion: featuresVersionRef.current }
|
||||
if (mapInstance) {
|
||||
const c = mapInstance.getCenter()
|
||||
body.mapCenter = { lng: c.lng, lat: c.lat }
|
||||
@@ -56,11 +62,22 @@ export function useAutoSave({
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
if (data && typeof data.featuresVersion === 'number') {
|
||||
featuresVersionRef.current = data.featuresVersion
|
||||
}
|
||||
console.log('[Auto-Save] Features gespeichert')
|
||||
socketRef.current?.emit('features-updated', {
|
||||
projectId: currentProject.id,
|
||||
features: featuresRef.current,
|
||||
})
|
||||
} else if (res.status === 409) {
|
||||
// Konflikt: jemand anderes hat zwischenzeitlich gespeichert → Server-Stand übernehmen
|
||||
const data = await res.json().catch(() => null)
|
||||
if (data?.conflict) {
|
||||
console.warn('[Auto-Save] Versionskonflikt — Server-Stand übernehmen')
|
||||
onConflict(data.features || [], data.featuresVersion ?? featuresVersionRef.current)
|
||||
}
|
||||
} else if (res.status === 404) {
|
||||
console.warn('[Auto-Save] Projekt nicht in DB')
|
||||
}
|
||||
@@ -70,7 +87,7 @@ export function useAutoSave({
|
||||
setSyncQueueCount(getSyncQueue().length)
|
||||
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
|
||||
}
|
||||
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount])
|
||||
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount, featuresVersionRef, onConflict])
|
||||
|
||||
// Debounced save on every feature change (2s delay)
|
||||
useEffect(() => {
|
||||
@@ -86,7 +103,7 @@ export function useAutoSave({
|
||||
// Nur der aktive Bearbeiter persistiert beim Schliessen — auch eine leere Liste,
|
||||
// damit "alles gelöscht + Tab zu" nicht die alten Elemente in der DB stehen lässt.
|
||||
if (currentProject?.id && isEditingByMe) {
|
||||
const payload = JSON.stringify({ features: featuresRef.current })
|
||||
const payload = JSON.stringify({ features: featuresRef.current, baseVersion: featuresVersionRef.current })
|
||||
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Project {
|
||||
mapCenter: { lng: number; lat: number }
|
||||
mapZoom: number
|
||||
isLocked: boolean
|
||||
featuresVersion?: number
|
||||
editingById?: string | null
|
||||
editingUserName?: string | null
|
||||
editingStartedAt?: string | null
|
||||
|
||||
Reference in New Issue
Block a user