Seiten
Inhalt
Peer Sync – Concept
Status: CONCEPT · Erstellt April 2026 Übergeordnet:
TOOL_chat_roadmap.mdVerwandt:@xpulse/sync,COMP_event_concept-message-structure.md
Warum ein eigenes Konzept?
xPulse Chat ist P2P und serverless – Nachrichten werden nie auf einem Server gepuffert. Das bedeutet: wenn zwei Peers nicht gleichzeitig online sind, entstehen Lücken. Dieses Konzept legt fest wie diese Lücken geschlossen werden – ohne Server, ohne zentrale Autorität, robust und privacy-by-design.
Grundprinzip
Sync passiert ausschließlich P2P über den DataChannel, beim Verbindungsaufbau. Es gibt zwei unabhängige Sync-Typen – sie wissen nichts voneinander:
- Profile Sync – Profil-Daten (Display Name, Avatar, ...)
- Chat Sync – Nachrichtenverlauf
Beide werden durch syncer.sync() getriggert – von außen, z.B. beim Connect-Event.
Der Syncer selbst entscheidet nie wann er läuft.
Message Format & Graveyard
Message-Struktur (v1), UI-Darstellung, Lösch-Modi, Graveyard und Migration v0 → v1 sind vollständig dokumentiert in:
→ TOOL_chat_concept-message.md
Für den Sync relevant:
id– Basis für Duplikat-CheckeditedAt– Basis für Edit-Vergleich im Comparatorv– Basis für Migration im Converter- Graveyard (
xp_graveyard_{chatId}) – Basis für Tombstone-Logik
@xpulse/sync – Package Struktur
Der Sync-Mechanismus ist als eigenständiges Package realisiert –
analog zu @xpulse/cli: Infrastruktur bereitstellen, konkrete Implementierung
bleibt beim Tool.
Abstrakte Basisklassen
| /sync |
| Provider – stellt Daten bereit (collect()) |
| Receiver – empfängt Daten vom DataChannel (listen()) |
| Converter – wandelt rohe Daten in internes Format um (convert()) |
| Comparator – vergleicht eingehende mit lokalen Daten (compare()) |
| Syncer – orchestriert die Pipeline (sync()) |
Kein gegenseitiges Wissen zwischen den Klassen – jede kennt nur ihre eigene Aufgabe. Der Syncer kennt nur die abstrakten Basisklassen.
Pipeline
| Provider → [DataChannel] → Receiver → Converter → Comparator |
- Provider sammelt lokale Daten und schickt sie über den DataChannel
- Receiver hört auf den DataChannel und empfängt rohe Daten
- Converter kennt das konkrete Eingangsformat und normalisiert es
- Comparator entscheidet was gemergt, ignoriert oder markiert wird
Provider und Receiver kennen sich nicht – der DataChannel ist der einzige Vertrag zwischen beiden Seiten. Jeder Peer ist gleichzeitig Provider und Receiver.
Trigger
| // Syncer wird von außen getriggert – er entscheidet nie selbst |
| peerConnection.on('connected', () => chatSyncer.sync()); |
| // oder via Cron (zukünftig) |
| cron.every('5m', () => chatSyncer.sync()); |
Konkrete Implementierung in xpulse-chat
Ordnerstruktur
| xpulse-chat/src/syncer/ |
| profile/ |
| ProfileProvider.js |
| ProfileReceiver.js |
| ProfileConverter.js |
| ProfileComparator.js |
| ProfileSyncer.js |
| chat/ |
| ChatProvider.js |
| ChatReceiver.js |
| ChatConverter.js |
| ChatComparator.js |
| ChatSyncer.js |
Zukünftig:
| device/ |
| DeviceProvider.js |
| ... |
| DeviceSyncer.js |
ChatSyncer Beispiel
| class ChatSyncer extends Syncer { |
| constructor() { |
| super( |
| new ChatProvider(), // holt Nachrichten aus localStorage |
| new ChatReceiver(), // hört auf DataChannel |
| new ChatConverter(), // kennt das Chat Message Format + Migration |
| new ChatComparator() // mergt, prüft Graveyard, ignoriert Duplikate |
| ); |
| } |
| // atomar – eine einzelne Message |
| async sync(msg) { |
| const converted = this.converter.convert(msg); |
| return this.comparator.compare(converted); |
| } |
| // ein Chat – alle Messages iterieren |
| async syncChat(chatIdOrObject) { |
| const messages = await this.provider.collect(chatIdOrObject); |
| for (const msg of messages) { |
| await this.sync(msg); |
| } |
| } |
| // alle Chats – ruft syncChat() pro Chat auf |
| async syncAll() { |
| const chats = await this.provider.collectAll(); |
| for (const chat of chats) { |
| await this.syncChat(chat); |
| } |
| } |
| } |
Alle Einstiegspunkte landen bei derselben atomaren sync(msg) –
der Syncer bleibt dumm, der Provider weiß wie er an die Messages kommt.
Comparator Logik (Chat)
| Message kommt rein → |
| ID im Graveyard? → ignorieren (Tombstone gewinnt) |
| ID schon lokal, kein editedAt? → ignorieren (Duplikat) |
| ID schon lokal, editedAt neuer? → lokale Message updaten (Edit gewinnt) |
| ID neu? → mergen (einfach hinzufügen) |
| Echter Konflikt? → nicht erwartet (UUID v4 + Edit nur eigene |
| Messages macht echte Konflikte praktisch |
| unmöglich) → undefined behavior, reserved for future |
onEdit triggert ebenfalls einen Sync – der Comparator vergleicht editedAt
und lässt die neuere Version gewinnen.
Default: merge-on-new – kein kompliziertes Conflict-Resolution nötig.
DataChannel-Protokoll
Jede Sync-Message über den DataChannel hat einen gemeinsamen Envelope:
| { |
| type: String, // siehe unten |
| payload: Object, // je nach type |
| v: Number, // Protokoll-Version (aktuell: 1) |
| } |
Chat Sync
History – beim Connect, sofort gemergt:
| // Request |
| { type: 'sync:request:history', v: 1, payload: { chatId, knownIds: [...] } } |
| // Response – Peer schickt Messages die nicht in knownIds sind |
| // und nicht in seinem eigenen Graveyard liegen |
| { type: 'sync:response:history', v: 1, payload: { chatId, messages: [...] } } |
Restorable – beim Connect, für Chat Info Panel:
| // Request – eigene Graveyard-IDs mitschicken |
| { type: 'sync:request:restorable', v: 1, payload: { chatId, graveyardIds: [...] } } |
| // Response – Anzahl + IDs die der Peer hat und lokal im Graveyard liegen |
| { type: 'sync:response:restorable', v: 1, payload: { chatId, count: 3, ids: [...] } } |
Restore – manuell via "Aktualisieren" Button im Chat Info Panel:
| // Request – gezielt die restorable IDs anfragen |
| { type: 'sync:request:restore', v: 1, payload: { chatId, ids: [...] } } |
| // Response – die Messages zu diesen IDs |
| { type: 'sync:response:restore', v: 1, payload: { chatId, messages: [...] } } |
Delete – "Für alle löschen":
| { type: 'sync:delete', v: 1, payload: { chatId, id, requestedAt } } |
Profile Sync
Beim Connect – Peer fragt aktiv an:
| { type: 'sync:request:profile', v: 1, payload: {} } |
| { type: 'sync:response:profile', v: 1, payload: { ...publicFields, updatedAt } } |
Bei Profil-Änderung – Notify-Pattern, Peer zieht selbst:
| // Sender: "ich hab was neues" |
| { type: 'sync:notify:profile', v: 1, payload: {} } |
| // Peer antwortet mit sync:request:profile → normaler Response-Flow |
Fehlerfall – Chat Sync
Bleibt eine sync:response:* aus:
| Timeout: 3s pro Versuch |
| Retries: 3x |
| Nach 3 Fehlversuchen → |
| System-Message in Chat einfügen: |
| { type: 'system', content: 'sync:fail', sentAt: Date.now(), ... } |
| → beim nächsten Connect automatisch neuer Versuch |
System-Messages nutzen dieselbe Message-Struktur (v1) –
type: 'system' signalisiert der UI dass kein Nachrichtentext,
sondern eine Systemmeldung gerendert werden soll.
Profile Sync und Chat Sync sind vollständig getrennt:
| Profile Sync | Chat Sync | |
|---|---|---|
| Trigger | connected Event + sync:notify:profile |
connected Event |
| Provider | Profil aus localStorage | Nachrichten aus localStorage |
| Converter | Profil-Format | Message-Format (inkl. v) |
| Comparator | updatedAt Vergleich |
Graveyard + Duplikat-Check |
| localStorage Key | xp_profile_{peerId} |
xp_chat_{chatId} + xp_graveyard_{chatId} |
Beide wissen nichts voneinander. Beide können unabhängig getriggert werden.
Welche Profil-Felder gesynct werden bestimmt der User über den public-Block
im Profil – nur Felder mit public.{field} === true werden übertragen.
Technische Felder (login, clientId, createdAt, preferences) werden
niemals gesynct, unabhängig von den Einstellungen.
→ Details: TOOL_chat_concept-profile.md
Chat Info Panel Integration
Wenn der Peer online ist, zeigt das Chat Info Panel:
„3 Nachrichten wiederherstellbar" [Wiederherstellen]
Technisch: beim Connect vergleicht der Comparator die eingehenden IDs mit dem lokalen Graveyard. Die Differenz (IDs die im Graveyard stehen aber vom Peer angeboten werden) ergibt die wiederherstellbare Anzahl.
Wiederherstellung ist immer explizites Opt-in – nie automatisch.
Migration
Migration v0 → v1, migrateV0() und Comparator-Fallback für migrierte Messages
sind vollständig dokumentiert in:
→ TOOL_chat_concept-message.md
Offene Punkte
| Thema | Stand |
|---|---|
Payload-Größe bei vielen Messages (knownIds) |
Bewusst ignoriert – erst bei konkretem Performance-Problem |
| Verhalten bei sehr langer History (DataChannel Größenlimit) | TBD |
At-rest Encryption für content in localStorage |
Bewusst offen – @xpulse/crypto |
| Avatar-Sync Größenlimit (DataChannel ~256KB chunks) | TBD – relevant ab v1.8.0 |
| Device Sync (Multi-Device Trust-Modell) | Konzept offen – nach v1.11.0 |
| Zeitfenster für Edit + „Für alle löschen" | TBD – Richtwert: 48–60h ab sentAt |