╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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));│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/event/Issue.zig│ ╰───────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │13 // what the db stores: the event's data plus the commit-derived fields │ │14 pub const Record = struct { │ │15 event: Self, │ │16 + deleted: bool = false, │ │17 author_email: ?[]const u8 = null, │ │18 created_ts: u64 = 0, // the commit timestamp of the event that first created this issue│ │19 }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 92 const issue_id_to_field_to_oid_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, id_to_field_to_oid_key)); │ │ 93 const issue_id_to_field_to_oid = try DB.HashMap(.read_write).init(issue_id_to_field_to_oid_cursor); │ │ 94 │ │ 94 - if (record_maybe) |record| { │ │ 95 - var record_to_write = record; │ │ 95 + var existing_record_maybe: ?Record = null; │ │ 96 + const existing_cursor_maybe = try event_id_to_issue.getCursor(issue_key); │ │ 97 + if (existing_cursor_maybe) |existing_cursor| { │ │ 98 + const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); │ │ 99 + existing_record_maybe = try evt.read(Record, DB, hash_kind, arena, existing_issue); │ │100 + } │ │101 │ │ 97 - var existing_record_maybe: ?Record = null; │ │ 98 - const existing_cursor_maybe = try event_id_to_issue.getCursor(issue_key); │ │ 99 - if (existing_cursor_maybe) |existing_cursor| { │ │100 - // updates preserve the original creation timestamp and author │ │101 - const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); │ │102 - const existing_record = try evt.read(Record, DB, hash_kind, arena, existing_issue); │ │103 - existing_record_maybe = existing_record; │ │104 - record_to_write.created_ts = existing_record.created_ts; │ │105 - record_to_write.author_email = existing_record.author_email; │ │102 + var record_to_write = if (record_maybe) |record| │ │103 + record │ │104 + else blk: { │ │105 + var record = existing_record_maybe orelse return error.EventNotFound; │ │106 + record.deleted = true; │ │107 + break :blk record; │ │108 + }; │ │109 + │ │110 + if (existing_record_maybe) |existing_record| { │ │111 + // updates preserve the original creation timestamp and author │ │112 + record_to_write.created_ts = existing_record.created_ts; │ │113 + record_to_write.author_email = existing_record.author_email; │ │114 │ │107 - // drop the old status's and tags' entries; the current ones are re-added below │ │108 - const order_key = evt.orderKeyDesc(existing_record.created_ts, event_id); │ │115 + // drop the old status's and tags' entries; active values are re-added below │ │116 + const order_key = evt.orderKeyDesc(existing_record.created_ts, event_id); │ │117 + if (!existing_record.deleted) { │ │118 const status_set = try statusSet(DB, status_to_issues, existing_record.event.status); │ │119 _ = try status_set.remove(&order_key); │ │120 try removeFromTagSets(DB, tag_to_issues, existing_record.event.tags, existing_record.event.status, &order_key);│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │112 - │ │113 - // any event settles the conflict, since resolving in the ui may │ │114 - // keep our own values and so change nothing to detect │ │115 - if (event_oid != null) _ = try conflicts.remove(&order_key); │ │121 } │ │122 │ │118 - try evt.writeOid(Self, DB, hash_kind, issue_id_to_field_to_oid, issue_key, if (existing_record_maybe) |existing| existing.event else null, record.event, event_oid); │ │123 + // any event settles the conflict, since resolving in the ui may │ │124 + // keep our own values and so change nothing to detect │ │125 + if (event_oid != null or record_to_write.deleted) _ = try conflicts.remove(&order_key); │ │126 + } │ │127 │ │120 - const issue_cursor = try event_id_to_issue.putCursor(issue_key); │ │121 - const issue = try DB.HashMap(.read_write).init(issue_cursor); │ │122 - try evt.upsert(Record, DB, hash_kind, issue, record_to_write); │ │128 + try evt.writeOid(Self, DB, hash_kind, issue_id_to_field_to_oid, issue_key, if (existing_record_maybe) |existing| existing.event else null, record_to_write.event, event_oid);│ │129 │ │124 - const order_key = evt.orderKeyDesc(record_to_write.created_ts, event_id); │ │130 + const issue_cursor = try event_id_to_issue.putCursor(issue_key); │ │131 + const issue = try DB.HashMap(.read_write).init(issue_cursor); │ │132 + try evt.upsert(Record, DB, hash_kind, issue, record_to_write); │ │133 │ │126 - // first time we've seen this issue: add it to the set that enumerates │ │127 - // every issue regardless of status │ │128 - if (existing_cursor_maybe == null) { │ │129 - const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, id_set_key)); │ │130 - const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); │ │131 - try issue_id_set.put(&order_key); │ │132 - } │ │134 + const order_key = evt.orderKeyDesc(record_to_write.created_ts, event_id); │ │135 │ │134 - const status_set = try statusSet(DB, status_to_issues, record.event.status); │ │136 + // the id set retains tombstones so merges can carry deletions │ │137 + if (existing_cursor_maybe == null) { │ │138 + const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, id_set_key)); │ │139 + const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); │ │140 + try issue_id_set.put(&order_key); │ │141 + } │ │142 + │ │143 + if (!record_to_write.deleted) { │ │144 + const status_set = try statusSet(DB, status_to_issues, record_to_write.event.status); │ │145 try status_set.put(&order_key); │ │146 │ │137 - var tag_iter = tagIterator(record.event.tags); │ │147 + var tag_iter = tagIterator(record_to_write.event.tags); │ │148 while (tag_iter.next()) |tag| { │ │149 // an over-long tag goes unindexed rather than failing the consume, │ │150 // which would wedge the branch it arrived on │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │151 if (tag.len == 0 or tag.len > tag_max_len) continue; │ │152 var key_buffer: TagStatusKey = undefined; │ │143 - const tag_set_cursor = try tag_to_issues.putCursor(try tagStatusKey(&key_buffer, tag, record.event.status)); │ │153 + const tag_set_cursor = try tag_to_issues.putCursor(try tagStatusKey(&key_buffer, tag, record_to_write.event.status));│ │154 const tag_set = try DB.SortedSet(.read_write).init(tag_set_cursor); │ │155 try tag_set.put(&order_key); │ │156 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯