xPulse
🇬🇧 EN

Peer Sync – Concept

Status: CONCEPT · Erstellt April 2026 Übergeordnet: TOOL_chat_roadmap.md Verwandt: @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:

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:


@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

@xpulse/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 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
de/concept/peer-sync.md 2026-04-17