When two devices change the same vault, a sync combines both sets of changes instead of keeping one
and losing the other. It first merges each entry edited on both sides field by field, against the last
version both had (the base copy): a field only one side changed takes that side’s value, and a field
both changed takes the newer one and is reported in SyncResult.conflicts. Then it merges the
structure (entries and groups added, moved or deleted), as specified below. Most apps only need this
paragraph; the rest is the exact specification.
Structure merge: required behaviour
mergeInto(source, target, mode) brings one database’s changes into another. Sync runs it twice
(local → remote, then remote → local) after the field-by-field step (combineEntries), so both
copies end up equal. The OpenVireo Qt client syncs the same files, so the observable result must
match what it produces; packages/sdk/test/fixtures/merge-fixtures/ holds results recorded from the Qt client.
This document is the specification packages/sdk/src/merge/tree.ts is written from. It describes outcomes only.
Terms
- Time of an entry or group: its last modification time, compared in whole seconds (KDBX files store seconds, so a copy that was saved and reopened has lost its milliseconds). A missing time counts as 0.
- Location time: the time an item last changed parent. Compared in milliseconds, missing = 0.
- Counterpart: the item with the same UUID anywhere in the target tree. The matching group for a source item is the target group that corresponds to the source item’s parent (the target root for the source root).
- Copy of an entry: an entry with the same UUID and all of the original’s data (fields and their protection, attachments, times, icon, colours, tags, URL override, auto-type, custom data items, quality-check flag, previous parent), its attachments added to the target’s binary pool and its custom icon added to the target’s icons when missing there. A full copy also has copies of the history items (which never have history of their own); a plain copy has none.
- Appending an item to a group detaches it from its current parent and adds it as the group’s last child.
- History limit: the target’s
historyMaxItems. Trimming keeps only the newest items when the limit is set and not negative.
Change records
Every action returns records {type, uuid?, title?, group?, details}. For an entry: its UUID, its
Title and its parent group’s name. For a group: its UUID and its own name in group. For
database-wide actions: only type and details. The records are listed in this order: tree
walk, then deletions, then metadata.
1. Tree walk
Visit the source tree from its root. For each source group, handle its entries in order first, then its child groups in order. Each child group is handled (step 1.2) before its own contents.
1.1 Entries
- No counterpart: append a full copy to the matching group. Record
added, details"". - Counterpart exists:
- If the source entry’s location time is later than the counterpart’s and the counterpart is not
in the matching group, append the counterpart to the matching group (its location time is not
changed). Record
movedfor the source entry, detailsPrevious location: <name of the counterpart's old parent>. - Then compare times:
- Counterpart older: replace it with a full copy of the source entry. The copy’s history is
merged (§2) with the counterpart as the other side, and the copy is appended to the group the
counterpart was in. Always record
modifiedfor the counterpart (its title and group as they were), detailsAdd local modifications to new entry. - Same time or counterpart newer: merge the source entry’s history into the counterpart
(§2). Record
modified, detailsAdd new modifications to existing entry, only if that changed anything.
- Counterpart older: replace it with a full copy of the source entry. The copy’s history is
merged (§2) with the counterpart as the other side, and the copy is appended to the group the
counterpart was in. Always record
- If the source entry’s location time is later than the counterpart’s and the counterpart is not
in the matching group, append the counterpart to the matching group (its location time is not
changed). Record
1.2 Groups
- No counterpart: create a group with the source group’s own properties (name, notes, icon,
times, flags, custom data; no children), append it to the matching group, and give it the source’s
location time. Add its custom icon to the target’s icons when missing. Record
added, detailsNumber of entries in group: <number of entries directly in the source group>. - Counterpart exists:
- If the source’s location time is later and the counterpart is not in the matching group,
append it there and copy the source’s location time. Record
moved, detailsPrevious location: <the counterpart group's own name>. - If the counterpart’s time is earlier than the source’s (whole seconds), update it and record
modifiedwith details listing what changed, joined by", ", in this order:Group namewhen the names differ (take the source’s);Noteswhen they differ (missing = empty);- icon: if the source has a non-empty custom icon whose UUID differs from the counterpart’s,
take it (
Icon (UUID)) and add the icon image to the target when missing; otherwise, if the standard icon numbers differ or the counterpart has a custom icon, take the source’s number and clear the custom icon (Icon (Number)); Expiry timewhen the expiry times differ in whole seconds (take the source’s);- always
Modification time(take the source’s).
- If the source’s location time is later and the counterpart is not in the matching group,
append it there and copy the source’s location time. Record
- Then walk the source group’s contents into its counterpart (or the new group).
2. History merge
Merging the history of entry S (other side) into entry T (the one being kept):
- Index history items by their time in whole seconds; when two share a time, the later one wins. Start from copies of T’s history items.
- Add each of S’s history items whose time isn’t in the index yet. If S is newer than T, S’s items also replace T’s items with the same time.
- If T is older than S, add a plain copy of T itself when its time isn’t present. If T is newer than S, add a plain copy of S itself when its time isn’t present. If they have the same time, add neither.
- Sort by time, oldest first.
- Decide whether this counts as a change by comparing the old and new lists from the newest end.
With
N= the history limit (10 if unset), the positions checked are the lastN - 1items of each list (so a limit of 0, 1 or a negative number never counts as a change). It is a change as soon as a position is filled in only one list or holds items that differ (§3). - Only if it is a change: T’s history becomes the new list, trimmed to the history limit.
3. Entry equality
Two entries are equal when all of these match: UUID; the same set of fields (attributes, including the five standard ones, which count as present and empty when missing) with equal text and equal protection flags; attachments by name and content; tags; expiry (whether it expires and when, in seconds); icon (custom icon UUID, or the standard number); foreground and background colours; modification, creation, last access and location times in whole seconds; usage count; URL override; auto-type settings; custom data keys and values; previous parent group; and their histories, item by item.
4. Deletions
Only in synchronize mode; keep-newer (the sync default) skips this step and keeps the target’s
list of deleted objects as it is.
- Go through the target’s deletion records, then the source’s, skipping any without a UUID or a time. The first record for a UUID decides what it refers to: an entry in the target, a non-root group in the target, or nothing there (then that first record is kept as is). A later record for the same UUID with an earlier time becomes the record used for that entry or group.
- Each entry with a record: if it was modified after the deletion time (milliseconds), keep the
entry and drop the record. Otherwise remove the entry, keep the record, and record
deletedwith detailsExplicit deletion. - Groups with a record, deepest first (a group waits while any of its child groups are still
waiting): keep it and drop the record if it was modified after the deletion time or still
contains any entries or groups. Otherwise remove it, keep the record and record
deleted(Explicit deletion). - If the resulting list (UUIDs and times, in order) differs from the target’s current list, it
replaces it, recorded as
metadatawith detailsChanged deleted objects.
5. Database metadata
- Each custom icon of the source missing in the target is added:
metadata,Adding new icon <uuid>. - Database custom data. Its “last modified” time is the
_LAST_MODIFIEDitem’s value when that parses as a date, otherwise the latestlastModifiedof its items. The source’s custom data is applied when the target has no_LAST_MODIFIEDitem, or when both times exist and the target’s is earlier. Applying it:- removes target items the source doesn’t have (
Removed custom data <key> [<value>]), except the protected keys:_CREATED_,FDO_SECRETS_EXPOSED_GROUP,KPXC_REMOTE_SYNC_SETTINGS,KPXC_SYNC_GENERATIONand every key starting withKPXC_BROWSER_; - copies every source item whose value differs (
Adding custom data <key> [<value>]), except the generated keys_LAST_MODIFIED,KPXC_RANDOM_SLUGandKPXC_SYNC_GENERATION.
- removes target items the source doesn’t have (
The SDK docs cover OpenVireo SDK, develop (86e6cd2), last updated 26 October 2026. Found a mistake? Edit this page on GitHub.