Sync-Protokoll
Die App liest und schreibt eine lokale SQLite-WASM-Datenbank und gleicht sie
über SyncService mit dem Server ab. Diese Seite dokumentiert den
Wire-Vertrag aus proto/openbeehive/v1/sync.proto und die Merge-Regeln in
server/internal/sync/merge.go und app/src/lib/local/merge.ts.
Der Dienst
service SyncService {
rpc Pull(PullRequest) returns (PullResponse);
rpc Push(PushRequest) returns (PushResponse);
rpc Subscribe(SubscribeRequest) returns (stream SubscribeEvent);
}
Die Client-Schleife (app/src/lib/local/sync.ts, syncOnce): die Outbox
pushen, dann pullen, bis has_more false ist. Die Schleife läuft alle 15 Sekunden,
nach jedem lokalen Schreibvorgang und beim online-Event des Browsers. Läufe
werden serialisiert; ein Aufruf, der eintrifft, während einer läuft, plant
einen weiteren Durchgang. Der Client ruft Subscribe nicht auf.
Change
Jede Zeilenänderung reist als ein Change:
enum ChangeOp {
CHANGE_OP_UNSPECIFIED = 0;
CHANGE_OP_UPSERT = 1;
CHANGE_OP_DELETE = 2;
}
message Change {
string entity = 1; // table name: apiary, hive, queen, inspection, task,
// placement, harvest, treatment, event
string entity_id = 2; // row id (UUID minted on the device)
string scope_id = 3; // apiary id, or "user:<id>"
ChangeOp op = 4;
string payload_json = 5; // JSON object of changed columns; ignored on delete
string hlc = 6; // Hybrid Logical Clock of the write
string author_id = 7; // user id of the device or API caller that wrote it
}
payload_json ist ein partielles Delta, nicht die ganze Zeile: patch() des
Clients schreibt nur die Spalten, die es geändert hat. Die Schlüssel sind die
snake_case-Spaltennamen aus dem Datenmodell. Eine
auf dem Gerät angelegte Zeile ist ein Delta, das jede Spalte enthält. Ein
Löschen ist op = CHANGE_OP_DELETE; der Empfänger setzt deleted = true und
ignoriert das Payload.
Mengenspalten (derzeit nur inspection.photo_keys) verwenden eine andere
Payload-Form:
{ "photo_keys": { "add": ["k1"] } }
{ "photo_keys": { "remove": ["k2"], "removed_tags": { "k2": ["<hlc>", "<hlc>"] } } }
removed_tags listet die Add-Tags, die das entfernende Gerät gesehen hatte.
Deltas ohne dieses Feld (ältere Clients) entfernen jeden Tag, den der
Empfänger hält.
HLC-Format
"<ms:15>:<counter:5>:<node>", zum Beispiel
001781234567890:00003:a1b2c3d4. Wanduhr in Millisekunden, auf 15 Stellen
mit Nullen aufgefüllt, ein 5-stelliger Zähler, dann eine Node-Id (8 zufällige
Zeichen, auf dem Gerät in localStorage gespeichert; auf dem Server
BEEHIVE_NODE_ID, Vorgabe server). Ein einfacher String-Vergleich ordnet
HLCs. Beide Seiten rufen für jede eingehende HLC recv() auf, damit ihre
Uhren vor allem bleiben, was sie gesehen haben.
Pull
message PullRequest {
string cursor = 1; // last seen server sequence, "" for a first sync
int32 limit = 2; // 0 = server default (200), max 500
}
message PullResponse {
repeated Change changes = 1;
string next_cursor = 2;
bool has_more = 3;
}
Der Cursor ist der Dezimal-String der Empfangssequenz des Servers
(change_log.seq), keine HLC. Der Server liefert Zeilen mit seq > cursor,
deren scope_id in der Scope-Menge des Aufrufers liegt, sortiert nach seq,
und setzt next_cursor auf die letzte zurückgegebene seq (oder gibt den
Request-Cursor zurück, wenn nichts passte). Persistiere next_cursor erst,
nachdem die ganze Seite angewendet wurde. has_more bedeutet, dass die Seite
bei limit abgeschnitten wurde; pulle sofort erneut.
Zeilen in Scopes, die du nicht lesen darfst, verbrauchen trotzdem Sequenznummern, die Werte, die du siehst, haben also Lücken.
Push
message PushRequest { repeated Change changes = 1; }
message Conflict {
string entity = 1;
string entity_id = 2;
string winning_hlc = 3;
}
message PushResponse {
string server_cursor = 1; // global sequence after this push
repeated Conflict conflicts = 2;
}
Der Server verarbeitet den Stapel in einer Transaktion:
hlc.Recv(change.hlc).- Unbekannte
entity-Werte werden übersprungen. scope_idmuss in der Scope-Menge des Aufrufers liegen, außer dass eineapiary-Änderung, derenscope_idihrer eigenenentity_identspricht, einen neuen Scope eröffnet. Jeder andere unbekannte Scope lässt den ganzen Push mitpermission_deniedfehlschlagen.- Die
organization_idim Payload muss, falls vorhanden und nicht leer, dem aktiven Mandanten des Aufrufers entsprechen; eine bestehende Zeile muss zu diesem Mandanten gehören. Neue Zeilen werden mit dem Mandanten des Aufrufers gestempelt. Eine Abweichung lässt den Push mitpermission_deniedfehlschlagen. - Die Änderung wird Feld für Feld zusammengeführt (siehe unten) und mit einer
frischen
seqanchange_logangehängt.
conflicts ist im aktuellen Server immer leer: Veraltete Felder werden beim
Merge stillschweigend verworfen, und der neuere Wert kommt beim nächsten
Pull. server_cursor ist die globale Sequenz nach dem Push; der Client
speichert sie als seinen Cursor, wodurch er seine eigenen Änderungen nicht
wieder zurückpullt.
Der Client verwirft den ganzen Outbox-Stapel, wenn Push mit
permission_denied antwortet (schreibgeschützte Demo-Sitzung oder ein nicht
beschreibbarer Scope). Die Zeilen bleiben in den lokalen Tabellen; nur der
Upload wird aufgegeben. Jeder andere Fehler behält die Outbox für den nächsten
Lauf.
Merge-Regeln
Beide Seiten wenden denselben Algorithmus an (applyChange in
server/internal/service/sync.go, applyRemote in
app/src/lib/local/sync.ts).
Jede synchronisierte Tabelle hat eine Spalte field_hlc mit einer JSON-Map
{ "<column>": "<hlc>" }, der Feld-Uhr.
Neue Zeile. Jede Spalte aus dem Payload einfügen und jede mit der HLC der Änderung stempeln.
Bestehende Zeile, skalare Spalte. Den Wert nur anwenden, wenn die HLC der
Änderung größer ist als der Eintrag der Spalte in field_hlc, dann diesen
Eintrag aktualisieren. Zwei Geräte, die verschiedene Spalten derselben Zeile
bearbeiten, gewinnen beide; zwei Geräte, die dieselbe Spalte bearbeiten, lösen
sich zur höheren HLC auf.
Bestehende Zeile, Mengenspalte. Der gespeicherte Wert ist ein OR-Set:
{ "<element>": { "a": ["<tag>", ...], "r": ["<tag>", ...] } }.
add-Elemente erhalten die HLC der Änderung als neuen Tag in a.
remove-Elemente verschieben die in removed_tags gelisteten Tags (oder alle
aktuellen a-Tags) nach r. Ein Element ist sichtbar, solange es einen Tag in
a hat, der nicht in r ist, sodass ein Add, das ein Entfernender nie gesehen
hat, überlebt (add-wins). Mengenspalten werden nie durch LWW überschrieben.
Löschen. deleted ist eine normale skalare Spalte und folgt LWW mit der
HLC des Löschens. Zeilen werden nie entfernt; Leser filtern deleted = 0.
Wenn nichts in der Änderung die Feld-Uhr schlägt, bleibt die Zeile unberührt.
Scopes
Der Server berechnet die Scope-Menge eines Aufrufers als:
{ "user:<user id>" }
∪ { id of every apiary in the caller's active tenant }
∪ { apiary_id from apiary_share rows for the caller }
Dieselbe Menge steuert Pull und Push. Standorte verwenden ihre eigene Id
als scope_id; alles unter einem Standort (Beuten, Königinnen, Durchsichten,
Aufgaben, Placements, Ernten, Behandlungen, Ereignisse) trägt die Standort-Id.
Nichts in der aktuellen App schreibt apiary_share, in der Praxis ist die
Scope-Menge also die Standorte des Mandanten.
Subscribe
message SubscribeRequest { string cursor = 1; }
message SubscribeEvent { string server_cursor = 1; }
Ein Server-Stream. Alle zwei Sekunden liest der Server den globalen
Sequenzzähler und sendet server_cursor, wenn er über den Request-Cursor und
über den zuletzt gesendeten Wert hinaus vorgerückt ist. Er trägt keine
Änderungen; ein Empfänger ruft Pull auf. Der Zähler ist global, nicht pro
Scope, ein Event kann also zu einem leeren Pull führen. Die App nutzt ihn
nicht.
Serverseitige Tabellen
change_log ist der Feed: seq, scope_id, entity, entity_id, op,
payload, hlc, author_id, org_id. seq_counter hält die einzelne
Zählerzeile (name = 'change'), die pro angenommener Änderung erhöht wird.
Auf dem Client hält outbox noch nicht gepushte Änderungen in derselben Form,
und sync_meta speichert den Cursor unter dem Schlüssel cursor.
Änderungen vom Server
Die CRUD-Dienste (ApiaryService, HiveService, QueenService,
InspectionService, TaskService, TreatmentService) schreiben die
Entitätstabellen nicht direkt. Jeder schreibende RPC baut einen Change und
lässt ihn innerhalb einer Transaktion durch applyChange und
appendChangeLog laufen (server/internal/service/writer.go), dieselben
zwei Schritte, die Push pro Änderung ausführt. Eine solche Änderung hat als
author_id die Benutzer-Id des API-Aufrufers, eine hlc aus der eigenen
Uhr des Servers (Node-Id BEEHIVE_NODE_ID; die Uhr wird mit dem
Push-Handler geteilt, damit sie vor allem Empfangenen bleibt) und als
scope_id den Standort, zu dem die Zeile gehört: die eigene Id eines
Standorts, der Standort der Beute bei Beuten, Königinnen, Durchsichten und
Behandlungen, und user:<id> bei einer Aufgabe ohne Standort. Das
payload_json ist ein partielles Delta wie das eines Geräts, sodass eine
spätere Gerätebearbeitung einer anderen Spalte damit zusammengeführt wird,
und die Zeile taucht im nächsten Pull jedes Geräts auf, das den Scope
lesen darf. Löschungen über die API sind CHANGE_OP_DELETE-Tombstones. Die
mehrzeiligen Abläufe (Beute plus Placement plus Ereignis, Königinnenwechsel,
eingefrorener Kontext für Durchsichten und Behandlungen) hängen eine Änderung
pro Zeile an und spiegeln app/src/lib/local/history.ts.