╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────╮ │12 pub const Comment = @import("event/Comment.zig"); │ │13 │ │14 pub const event_id_size: usize = 16; │ │15 + │ │16 +pub const MergePolicy = enum { │ │17 + field_conflicts, │ │18 + target_wins, │ │19 +}; │ │20 │ │21 // the most bytes an event's serialized form may hold│ │22 pub const max_event_size: usize = 100 * 1024; │ ╰─────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │758 return merged; │ │759 } │ │760 │ │756 -// three-way merge of one kind's records from a merge parent, field by field, so│ │757 -// sides editing different fields combine cleanly. the result goes through the │ │758 -// kind's `consume`, which re-derives the indexes from it. a field both sides │ │759 -// changed differently keeps the target's value and records a conflict entry. a │ │761 +// three-way merge of one kind's records from a merge parent. kinds that expose │ │762 +// conflicts merge field by field; the others take one whole record. the result │ │763 +// goes through the kind's `consume`, which re-derives the indexes from it. a │ │764 // deletion carries over unless the other side changed the record, so a merge │ │765 // never fails. │ │766 pub fn merge( │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │784 } │ │785 │ │786 var parent_conflicts: ?DB.SortedMap(.read_only) = null; │ │783 - if (try parent_moment.getCursor(hash.hashInt(hash_kind, T.conflicts_key))) |cursor| { │ │784 - parent_conflicts = try DB.SortedMap(.read_only).init(cursor); │ │785 - } │ │786 - │ │787 var baseline_conflicts: ?DB.SortedMap(.read_only) = null; │ │788 - if (try baseline_moment.getCursor(hash.hashInt(hash_kind, T.conflicts_key))) |cursor| { │ │789 - baseline_conflicts = try DB.SortedMap(.read_only).init(cursor); │ │790 - } │ │791 - │ │788 var parent_id_to_field_to_oid: ?DB.HashMap(.read_only) = null; │ │793 - if (try parent_moment.getCursor(hash.hashInt(hash_kind, T.id_to_field_to_oid_key))) |cursor| { │ │794 - parent_id_to_field_to_oid = try DB.HashMap(.read_only).init(cursor); │ │789 + switch (comptime T.merge_policy) { │ │790 + .field_conflicts => { │ │791 + if (try parent_moment.getCursor(hash.hashInt(hash_kind, T.conflicts_key))) |cursor| { │ │792 + parent_conflicts = try DB.SortedMap(.read_only).init(cursor); │ │793 + } │ │794 + if (try baseline_moment.getCursor(hash.hashInt(hash_kind, T.conflicts_key))) |cursor| { │ │795 + baseline_conflicts = try DB.SortedMap(.read_only).init(cursor); │ │796 + } │ │797 + if (try parent_moment.getCursor(hash.hashInt(hash_kind, T.id_to_field_to_oid_key))) |cursor| {│ │798 + parent_id_to_field_to_oid = try DB.HashMap(.read_only).init(cursor); │ │799 + } │ │800 + }, │ │801 + .target_wins => {}, │ │802 } │ │803 │ │804 var arena = std.heap.ArenaAllocator.init(allocator); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │844 } │ │845 } │ │846 │ │840 - outcome = @splat(.kept); │ │841 - merged = target_record; │ │842 - merged.event = mergeFields(T, if (baseline_record) |baseline| baseline.event else null, target_record.event, parent_record.event, &outcome); │ │847 + switch (comptime T.merge_policy) { │ │848 + .field_conflicts => { │ │849 + outcome = @splat(.kept); │ │850 + merged = target_record; │ │851 + merged.event = mergeFields(T, if (baseline_record) |baseline| baseline.event else null, target_record.event, parent_record.event, &outcome);│ │852 │ │844 - // merge the tombstone separately from serialized fields. a deletion │ │845 - // carries over an unchanged record, while an edit or restoration │ │846 - // keeps it active. │ │847 - merged.deleted = if (target_record.deleted == parent_record.deleted) │ │848 - target_record.deleted │ │849 - else if (baseline_record) |baseline| blk: { │ │850 - const active = if (!target_record.deleted) target_record else parent_record; │ │851 - if (active.deleted != baseline.deleted) break :blk false; │ │852 - if (!fieldsEqual(T, active.event, baseline.event)) break :blk false; │ │853 - break :blk true; │ │854 - } else false; │ │853 + // merge the tombstone separately from serialized fields. a deletion │ │854 + // carries over an unchanged record, while an edit or restoration │ │855 + // keeps it active. │ │856 + merged.deleted = if (target_record.deleted == parent_record.deleted) │ │857 + target_record.deleted │ │858 + else if (baseline_record) |baseline| blk: { │ │859 + const active = if (!target_record.deleted) target_record else parent_record; │ │860 + if (active.deleted != baseline.deleted) break :blk false; │ │861 + if (!fieldsEqual(T, active.event, baseline.event)) break :blk false; │ │862 + break :blk true; │ │863 + } else false; │ │864 + }, │ │865 + .target_wins => { │ │866 + // an uncontested parent change carries over. if both sides │ │867 + // changed the record, the target wins as a whole, except that │ │868 + // an edit or restoration wins over a deletion. │ │869 + const target_changed = if (baseline_slot) |slot| │ │870 + !target_record_cursor.slot().eql(slot) │ │871 + else │ │872 + true; │ │873 + merged = if (!target_changed) │ │874 + parent_record │ │875 + else if (target_record.deleted != parent_record.deleted) │ │876 + if (target_record.deleted) parent_record else target_record │ │877 + else │ │878 + target_record; │ │879 + }, │ │880 + } │ │881 } │ │882 │ │883 // the index is unique, so a key the target gave to a different record │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │898 // a different side │ │899 try T.consume(DB, hash_kind, haxy_moment, &event_id, merged, &arena, null);│ │900 │ │901 + switch (comptime T.merge_policy) { │ │902 + .field_conflicts => {}, │ │903 + .target_wins => continue, │ │904 + } │ │905 + │ │906 // tombstones do not carry field conflicts │ │907 if (merged.deleted) continue; │ │908 │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────────╮ │src/event/Comment.zig│ ╰─────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────╮ │19 const Self = @This(); │ │20 │ │21 // the moment keys `evt.merge` reads and writes for this kind │ │22 +pub const merge_policy: evt.MergePolicy = .target_wins; │ │23 pub const record_map_key = "event-id->comment"; │ │24 pub const id_set_key = "comment-id-set"; │ │24 -pub const conflicts_key = "conflicted-comment-id->conflict"; │ │25 -pub const id_to_field_to_oid_key = "comment-id->field->oid"; │ │25 │ │26 // the indexes the thread reads │ │27 pub const thread_id_to_comment_id_set_key = "thread-id->comment-id-set";│ ╰────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────╮ │38 event_id: *const [evt.event_id_size]u8, │ │39 record_maybe: ?Record, │ │40 arena: *std.heap.ArenaAllocator, │ │42 - // the commit this event came from, recorded against every field it │ │43 - // changes. null from `merge`, which sets oids and conflicts itself.│ │44 - event_oid: ?[]const u8, │ │41 + _: ?[]const u8, │ │42 ) !void { │ │43 const comment_key = hash.hashInt(hash_kind, event_id); │ │44 │ ╰────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │51 const parent_id_to_comment_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, parent_id_to_comment_id_set_key));│ │52 const parent_id_to_comment_id_set = try DB.HashMap(.read_write).init(parent_id_to_comment_id_set_cursor); │ │53 │ │57 - const conflicts_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, conflicts_key)); │ │58 - const conflicts = try DB.SortedMap(.read_write).init(conflicts_cursor); │ │59 - │ │60 - const comment_id_to_field_to_oid_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, id_to_field_to_oid_key)); │ │61 - const comment_id_to_field_to_oid = try DB.HashMap(.read_write).init(comment_id_to_field_to_oid_cursor); │ │62 - │ │54 var existing_record_maybe: ?Record = null; │ │55 const existing_cursor_maybe = try event_id_to_comment.getCursor(comment_key); │ │56 if (existing_cursor_maybe) |existing_cursor| { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 81 // a comment cannot move between threads or positions in the thread │ │ 82 if (!std.mem.eql(u8, &existing_record.event.thread_id, &record_to_write.event.thread_id)) return error.ThreadChanged; │ │ 83 if (!std.mem.eql(u8, &existing_record.event.parent_id, &record_to_write.event.parent_id)) return error.ParentChanged; │ │ 93 - │ │ 94 - // any event settles the conflict, since resolving in the ui may │ │ 95 - // keep our own values and so change nothing to detect │ │ 96 - if (event_oid != null or record_to_write.deleted) { │ │ 97 - _ = try conflicts.remove(&evt.orderKeyDesc(existing_record.created_ts, event_id)); │ │ 98 - } │ │ 84 } │ │ 85 │ │101 - try evt.writeOid(Self, DB, hash_kind, comment_id_to_field_to_oid, comment_key, if (existing_record_maybe) |existing| existing.event else null, record_to_write.event, event_oid);│ │102 - │ │ 86 const comment_cursor = try event_id_to_comment.putCursor(comment_key); │ │ 87 const comment = try DB.HashMap(.read_write).init(comment_cursor); │ │ 88 try evt.upsert(Record, DB, hash_kind, comment, record_to_write); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/event/Issue.zig│ ╰───────────────────╯ ╭─────────────────────────────────────────────────────────────────╮ │34 pub const tag_max_len = 64; │ │35 │ │36 // the moment keys `evt.merge` reads and writes for this kind│ │37 +pub const merge_policy: evt.MergePolicy = .field_conflicts; │ │38 pub const record_map_key = "event-id->issue"; │ │39 pub const id_set_key = "issue-id-set"; │ │40 pub const conflicts_key = "conflicted-issue-id->conflict"; │ ╰─────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯