feat(android): a vault born here can be given a repository

Until now the two situations never met: a local vault had no repository, a
pulled vault had no local container. The day both exist, the rule "the local
container is the authority" means saves never reach the repository - and the
two diverge in silence, which is the worst outcome available.

Sync now merges them once and then deletes the local container: the
repository becomes the authority and the device behaves like any other. That
makes divergence impossible rather than unlikely. Order matters - publish
first, delete after; the reverse would lose the whole vault if the publish
failed.

The remote is the base, never the local. It carries what the phone cannot
rebuild: attachment bodies, the avatar, folder colours, and whatever the
desktop adds next. Starting from the local would amputate all of it, and the
desktop would adopt the amputated version on its next pull.

Arbitration is the desktop's, term for term: last write wins on updated_at, a
tombstone beats an older entry but not one edited after it - that one was
revived on purpose. Timestamps are ISO 8601 UTC on both sides, milliseconds
included, so lexicographic order IS chronological order.

MergeSelfCheck asserts the four rules at startup in debug. The merge never
runs against a real vault before it has run there.
This commit is contained in:
r-zakarya
2026-08-21 17:47:58 +01:00
parent 0b0f1e3fcd
commit 2fa093d49c
4 changed files with 393 additions and 3 deletions
+127
View File
@@ -65,6 +65,25 @@ function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
/// une suppression qui s'y arreterait ne se propagerait donc nulle part.
function DeleteEntry(const APlainJson, AUuid: string): string;
/// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat.
///
/// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne
/// sait pas reconstruire - le contenu des pieces jointes, l'avatar du compte,
/// la couleur des dossiers, et tout ce que le poste ajoutera demain. Repartir
/// du local amputerait tout cela, et le poste adopterait la version amputee au
/// tirage suivant : la meme perte silencieuse et definitive contre laquelle
/// cette unite entiere est ecrite.
///
/// L'arbitrage est celui du poste, terme a terme : dernier ecrit gagnant sur
/// `updated_at`, et une pierre tombale l'emporte sur une entree plus ancienne
/// qu'elle. Les horodatages sont de l'ISO 8601 UTC des deux cotes,
/// millisecondes comprises, donc l'ordre lexicographique EST l'ordre
/// chronologique - aucune date a analyser.
///
/// Une entree sans uuid est ignoree : rien ne permettrait de la reconnaitre
/// chez l'autre, et l'ajouter en creerait un doublon a chaque fusion.
function MergeSnapshots(const ALocalJson, ARemoteJson: string): string;
/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS).
/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une
/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC.
@@ -100,6 +119,114 @@ begin
end;
end;
/// L'heure de suppression retenue pour un uuid, ou '' s'il n'en a pas.
/// La plus recente des deux listes : une entree ressuscitee puis resupprimee
/// doit rester supprimee.
function TombstoneTime(const AStones: TJSONArray; const AUuid: string): string;
var
I: Integer;
LObj: TJSONObject;
LWhen: string;
begin
Result := '';
if AStones = nil then Exit;
for I := 0 to AStones.Count - 1 do
begin
if not (AStones.Items[I] is TJSONObject) then Continue;
LObj := AStones.Items[I] as TJSONObject;
if LObj.GetValue<string>('uuid', '') <> AUuid then Continue;
LWhen := LObj.GetValue<string>('deleted_at', '');
if LWhen > Result then Result := LWhen;
end;
end;
/// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et
/// le seul qui n'oublie aucun champ.
function CloneJson(const AObj: TJSONObject): TJSONObject;
begin
Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject;
end;
function MergeSnapshots(const ALocalJson, ARemoteJson: string): string;
var
LLocal, LMerged: TJSONObject;
LLocalEntries, LOutEntries, LLocalStones, LOutStones: TJSONArray;
LEntry, LOther: TJSONObject;
I, J: Integer;
LUuid, LMine, LTheirs, LKilled: string;
LFound: Boolean;
begin
LLocal := TJSONObject.ParseJSONValue(ALocalJson) as TJSONObject;
if LLocal = nil then raise EPMAPatch.Create('Instantane local illisible');
try
LMerged := TJSONObject.ParseJSONValue(ARemoteJson) as TJSONObject;
if LMerged = nil then raise EPMAPatch.Create('Instantane distant illisible');
try
if not LMerged.TryGetValue<TJSONArray>('entries', LOutEntries) then
raise EPMAPatch.Create('Instantane distant sans tableau "entries"');
if not LMerged.TryGetValue<TJSONArray>('tombstones', LOutStones) then
begin
LOutStones := TJSONArray.Create;
LMerged.AddPair('tombstones', LOutStones);
end;
// 1. Les pierres tombales locales rejoignent celles du distant. Avant les
// entrees : l'etape suivante s'en sert pour arbitrer.
if LLocal.TryGetValue<TJSONArray>('tombstones', LLocalStones) then
for I := 0 to LLocalStones.Count - 1 do
if LLocalStones.Items[I] is TJSONObject then
LOutStones.AddElement(CloneJson(LLocalStones.Items[I] as TJSONObject));
// 2. Chaque entree locale : absente du distant on l'ajoute, presente on
// garde la plus recente des deux.
if LLocal.TryGetValue<TJSONArray>('entries', LLocalEntries) then
for I := 0 to LLocalEntries.Count - 1 do
begin
if not (LLocalEntries.Items[I] is TJSONObject) then Continue;
LEntry := LLocalEntries.Items[I] as TJSONObject;
LUuid := LEntry.GetValue<string>('uuid', '');
if LUuid = '' then Continue;
LMine := LEntry.GetValue<string>('updated_at', '');
LFound := False;
for J := 0 to LOutEntries.Count - 1 do
begin
if not (LOutEntries.Items[J] is TJSONObject) then Continue;
LOther := LOutEntries.Items[J] as TJSONObject;
if LOther.GetValue<string>('uuid', '') <> LUuid then Continue;
LFound := True;
LTheirs := LOther.GetValue<string>('updated_at', '');
if LMine > LTheirs then
begin
LOutEntries.Remove(J).Free;
LOutEntries.AddElement(CloneJson(LEntry));
end;
Break;
end;
if not LFound then LOutEntries.AddElement(CloneJson(LEntry));
end;
// 3. Une pierre tombale posterieure a l'entree la retire. Posterieure
// SEULEMENT : une entree modifiee apres coup a ete ressuscitee
// volontairement, et la retuer effacerait ce geste.
for J := LOutEntries.Count - 1 downto 0 do
begin
if not (LOutEntries.Items[J] is TJSONObject) then Continue;
LOther := LOutEntries.Items[J] as TJSONObject;
LKilled := TombstoneTime(LOutStones, LOther.GetValue<string>('uuid', ''));
if (LKilled <> '') and (LKilled > LOther.GetValue<string>('updated_at', '')) then
LOutEntries.Remove(J).Free;
end;
Result := LMerged.ToJSON;
finally
LMerged.Free;
end;
finally
LLocal.Free;
end;
end;
function DeleteEntry(const APlainJson, AUuid: string): string;
var
LRoot, LEntry, LStone: TJSONObject;