╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │779 │ │780 var arena = std.heap.ArenaAllocator.init(allocator); │ │781 defer arena.deinit(); │ │782 - │ │783 - // a record the parent deleted is deleted here too, unless the target │ │784 - // changed it. runs first so a delete and recreate is applied in that order. │ │785 - deletions: { │ │786 - const baseline_map = baseline_records orelse break :deletions; │ │787 - const baseline_set_cursor = try baseline_moment.getCursor(hash.hashInt(hash_kind, T.id_set_key)) orelse break :deletions; │ │788 - const baseline_set = try DB.SortedSet(.read_only).init(baseline_set_cursor); │ │789 - │ │790 - var baseline_iter = try baseline_set.iteratorFromIndex(0); │ │791 - while (try baseline_iter.next()) |kv_pair_cursor| { │ │792 - const event_id = try readOrderKeyId(DB, kv_pair_cursor); │ │793 - const record_key = hash.hashInt(hash_kind, &event_id); │ │794 - │ │795 - if (null != try parent_records.getSlot(record_key)) continue; │ │796 - const baseline_record_cursor = try baseline_map.getCursor(record_key) orelse continue; │ │782 │ │798 - _ = arena.reset(.retain_capacity); │ │799 - │ │800 - // re-derived every iteration, since `consume` writes through its │ │801 - // own handles │ │802 - const target_records_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, T.record_map_key)); │ │803 - const target_records = try DB.HashMap(.read_write).init(target_records_cursor); │ │804 - const target_record_cursor = try target_records.getCursor(record_key) orelse continue; │ │805 - │ │806 - if (!target_record_cursor.slot().eql(baseline_record_cursor.slot())) { │ │807 - // every write copies the record's block, so only a content │ │808 - // change means the target kept it │ │809 - const target_record = try read(T.Record, DB, hash_kind, &arena, try DB.HashMap(.read_only).init(target_record_cursor)); │ │810 - const baseline_record = try read(T.Record, DB, hash_kind, &arena, try DB.HashMap(.read_only).init(baseline_record_cursor));│ │811 - if (!fieldsEqual(T, target_record.event, baseline_record.event)) continue; │ │812 - } │ │813 - │ │814 - try T.consume(DB, hash_kind, haxy_moment, &event_id, null, &arena, null); │ │815 - } │ │816 - } │ │817 - │ │783 var iter = try parent_set.iteratorFromIndex(0); │ │784 while (try iter.next()) |kv_pair_cursor| { │ │785 const event_id = try readOrderKeyId(DB, kv_pair_cursor); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │803 const target_records_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, T.record_map_key));│ │804 const target_records = try DB.HashMap(.read_write).init(target_records_cursor); │ │805 │ │841 - // a record the target doesn't have, or deleted, arrives wholesale from │ │842 - // the parent │ │806 + // a record the target doesn't have arrives wholesale from the parent │ │807 var outcome: [std.meta.fields(T).len]FieldMerge = @splat(.parent); │ │808 var merged = parent_record; │ │809 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │813 │ │814 const target_record = try read(T.Record, DB, hash_kind, &arena, try DB.HashMap(.read_only).init(target_record_cursor)); │ │815 │ │852 - var baseline_event: ?T = null; │ │816 + var baseline_record: ?T.Record = null; │ │817 if (baseline_records) |map| { │ │818 if (try map.getCursor(record_key)) |baseline_record_cursor| { │ │855 - baseline_event = (try read(T.Record, DB, hash_kind, &arena, try DB.HashMap(.read_only).init(baseline_record_cursor))).event;│ │819 + baseline_record = try read(T.Record, DB, hash_kind, &arena, try DB.HashMap(.read_only).init(baseline_record_cursor)); │ │820 } │ │821 } │ │822 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │823 outcome = @splat(.kept); │ │824 merged = target_record; │ │861 - merged.event = mergeFields(T, baseline_event, target_record.event, parent_record.event, &outcome); │ │825 + merged.event = mergeFields(T, if (baseline_record) |baseline| baseline.event else null, target_record.event, parent_record.event, &outcome);│ │826 + │ │827 + // merge the tombstone separately from serialized fields. a deletion │ │828 + // carries over an unchanged record, while an edit or restoration │ │829 + // keeps it active. │ │830 + merged.deleted = if (target_record.deleted == parent_record.deleted) │ │831 + target_record.deleted │ │832 + else if (baseline_record) |baseline| blk: { │ │833 + const active = if (!target_record.deleted) target_record else parent_record; │ │834 + if (active.deleted != baseline.deleted) break :blk false; │ │835 + if (!fieldsEqual(T, active.event, baseline.event)) break :blk false; │ │836 + break :blk true; │ │837 + } else false; │ │838 } │ │839 │ │840 // the index is unique, so a key the target gave to a different record │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │841 // leaves this one out rather than stranding that one behind a name it │ │842 // no longer owns │ │867 - if (@hasDecl(T, "name_index_key")) { │ │843 + if (!merged.deleted and @hasDecl(T, "name_index_key")) { │ │844 const index_key = try merged.indexKey(arena.allocator()); │ │845 const name_index_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, T.name_index_key));│ │846 const name_index = try DB.HashMap(.read_write).init(name_index_cursor); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │855 // a different side │ │856 try T.consume(DB, hash_kind, haxy_moment, &event_id, merged, &arena, null); │ │857 │ │858 + // tombstones do not carry field conflicts │ │859 + if (merged.deleted) continue; │ │860 + │ │861 const conflicts_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, T.conflicts_key)); │ │862 const conflicts = try DB.SortedMap(.read_write).init(conflicts_cursor); │ │863 const id_to_field_to_oid_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, T.id_to_field_to_oid_key));│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯