╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │617 const parent_child = try DB.SortedSet(.read_only).init(kv_pair.value_cursor); │ │618 const baseline_child = try DB.SortedSet(.read_only).init(baseline_value_cursor); │ │619 try mergeChangedSetEntries(DB, allocator, target_child, parent_child, baseline_child); │ │620 + } else if (tag == .sorted_map) { │ │621 + const target_existing_cursor = try target.getCursor(kv_pair.hash) orelse return error.MergeConflict;│ │622 + if (target_existing_cursor.slot().tag != .sorted_map) { │ │623 + return error.UnexpectedTag; │ │624 + } │ │625 + │ │626 + const target_child_cursor = try target.putCursor(kv_pair.hash); │ │627 + const target_child = try DB.SortedMap(.read_write).init(target_child_cursor); │ │628 + const parent_child = try DB.SortedMap(.read_only).init(kv_pair.value_cursor); │ │629 + const baseline_child = try DB.SortedMap(.read_only).init(baseline_value_cursor); │ │630 + try mergeChangedSortedMapEntries(DB, allocator, target_child, parent_child, baseline_child); │ │631 } else if (is_top_level) { │ │632 // the moment-index is a top-level key in the haxy moment │ │633 // and is not a map. we don't want to do any merge of this. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │642 } │ │643 │ │644 try target.put(kv_pair.hash, .{ .slot = kv_pair.value_cursor.slot() }); │ │645 + } │ │646 + } │ │647 +} │ │648 + │ │649 +// three-way merge of a sorted map keyed by byte strings, like │ │650 +// mergeChangedMapEntries but recursing into sorted-set values. │ │651 +fn mergeChangedSortedMapEntries( │ │652 + comptime DB: type, │ │653 + allocator: std.mem.Allocator, │ │654 + target: DB.SortedMap(.read_write), │ │655 + parent: DB.SortedMap(.read_only), │ │656 + baseline: DB.SortedMap(.read_only), │ │657 +) !void { │ │658 + var parent_iter = try parent.iterator(); │ │659 + while (try parent_iter.next()) |kv_pair_cursor| { │ │660 + const kv_pair = try kv_pair_cursor.readKeyValuePair(); │ │661 + const key = try kv_pair.key_cursor.readBytesAlloc(allocator, null); │ │662 + defer allocator.free(key); │ │663 + │ │664 + const baseline_value_cursor = try baseline.getCursor(key) orelse { │ │665 + if (try target.getCursor(key)) |target_existing_cursor| { │ │666 + // both parents created this key. sets union (they hold │ │667 + // independently added members); anything else conflicts. │ │668 + const parent_tag = kv_pair.value_cursor.slot().tag; │ │669 + if (target_existing_cursor.slot().tag != parent_tag) { │ │670 + return error.UnexpectedTag; │ │671 + } │ │672 + if (parent_tag != .sorted_set) { │ │673 + return error.MergeConflict; │ │674 + } │ │675 + │ │676 + const target_child_cursor = try target.putCursor(key); │ │677 + const target_child = try DB.SortedSet(.read_write).init(target_child_cursor); │ │678 + const parent_child = try DB.SortedSet(.read_only).init(kv_pair.value_cursor); │ │679 + var set_iter = try parent_child.iterator(); │ │680 + while (try set_iter.next()) |set_kv_cursor| { │ │681 + const set_kv = try set_kv_cursor.readKeyValuePair(); │ │682 + const set_key = try set_kv.key_cursor.readBytesAlloc(allocator, null); │ │683 + defer allocator.free(set_key); │ │684 + try target_child.put(set_key); │ │685 + } │ │686 + continue; │ │687 + } │ │688 + │ │689 + try target.put(key, .{ .slot = kv_pair.value_cursor.slot() }); │ │690 + continue; │ │691 + }; │ │692 + │ │693 + const tag = kv_pair.value_cursor.slot().tag; │ │694 + │ │695 + if (tag != baseline_value_cursor.slot().tag) { │ │696 + return error.UnexpectedTag; │ │697 } │ │698 + │ │699 + if (kv_pair.value_cursor.slot().value == baseline_value_cursor.slot().value) { │ │700 + continue; │ │701 + } │ │702 + │ │703 + if (tag == .sorted_set) { │ │704 + const target_existing_cursor = try target.getCursor(key) orelse return error.MergeConflict;│ │705 + if (target_existing_cursor.slot().tag != .sorted_set) { │ │706 + return error.UnexpectedTag; │ │707 + } │ │708 + │ │709 + const target_child_cursor = try target.putCursor(key); │ │710 + const target_child = try DB.SortedSet(.read_write).init(target_child_cursor); │ │711 + const parent_child = try DB.SortedSet(.read_only).init(kv_pair.value_cursor); │ │712 + const baseline_child = try DB.SortedSet(.read_only).init(baseline_value_cursor); │ │713 + try mergeChangedSetEntries(DB, allocator, target_child, parent_child, baseline_child); │ │714 + } else { │ │715 + const target_value_cursor = try target.getCursor(key) orelse return error.MergeConflict; │ │716 + if (target_value_cursor.slot().tag != baseline_value_cursor.slot().tag) { │ │717 + return error.UnexpectedTag; │ │718 + } │ │719 + if (target_value_cursor.slot().value != baseline_value_cursor.slot().value) { │ │720 + return error.MergeConflict; │ │721 + } │ │722 + │ │723 + try target.put(key, .{ .slot = kv_pair.value_cursor.slot() }); │ │724 + } │ │725 } │ │726 } │ │727 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/event/Issue.zig│ ╰───────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │ 5 │ │ 6 title: []const u8, │ │ 7 description: []const u8, │ │ 8 -tags: []const u8, │ │ 8 +tags: []const u8, // NUL-separated │ │ 9 created_ts: u64 = 0, // the commit timestamp of the event that first created this issue│ │10 + │ │11 +pub const tag_max_len = 64; │ │12 + │ │13 +pub fn tagIterator(tags: []const u8) std.mem.SplitIterator(u8, .scalar) { │ │14 + return std.mem.splitScalar(u8, tags, 0); │ │15 +} │ │16 │ │17 // `created_ts` comes from the commit timestamp, not the event │ │18 // payload, so it must not appear in the JSON we output │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │40 const event_id_to_issue_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "event-id->issue"));│ │41 const event_id_to_issue = try DB.HashMap(.read_write).init(event_id_to_issue_cursor); │ │42 │ │43 + // maps each tag to the set of issues carrying it, so a tag page can seek │ │44 + // its window the same way the unfiltered issue list does. │ │45 + const tag_to_issues_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "tag->issue-id-set")); │ │46 + const tag_to_issues = try DB.SortedMap(.read_write).init(tag_to_issues_cursor); │ │47 + │ │48 if (event_maybe) |event| { │ │49 var event_to_write = event; │ │50 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────╮ │54 const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); │ │55 const existing_event = try evt.read(@This(), DB, hash_kind, arena, existing_issue);│ │56 event_to_write.created_ts = existing_event.created_ts; │ │57 + │ │58 + // drop the old tags' entries; the current tags are re-added below │ │59 + const order_key = evt.orderKey(existing_event.created_ts, event_id); │ │60 + try removeFromTagSets(DB, tag_to_issues, existing_event.tags, &order_key); │ │61 } else { │ │62 // first time we've seen this issue: stamp it with the commit timestamp │ │63 event_to_write.created_ts = created_ts; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │66 const issue_cursor = try event_id_to_issue.putCursor(issue_key); │ │67 const issue = try DB.HashMap(.read_write).init(issue_cursor); │ │68 try evt.upsert(@This(), DB, hash_kind, issue, event_to_write); │ │69 + │ │70 + const order_key = evt.orderKey(event_to_write.created_ts, event_id); │ │71 + │ │72 + var tag_iter = tagIterator(event.tags); │ │73 + while (tag_iter.next()) |tag| { │ │74 + if (tag.len == 0) continue; │ │75 + if (tag.len > tag_max_len) return error.TagTooLong; │ │76 + const tag_set_cursor = try tag_to_issues.putCursor(tag); │ │77 + const tag_set = try DB.SortedSet(.read_write).init(tag_set_cursor);│ │78 + try tag_set.put(&order_key); │ │79 + } │ │80 │ │81 // first time we've seen this issue: add it to the set the issues view │ │82 // lists, ordered by creation time. the key (orderKey) embeds the event│ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │84 if (existing_cursor_maybe == null) { │ │85 const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "issue-id-set"));│ │86 const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); │ │61 - const order_key = evt.orderKey(event_to_write.created_ts, event_id); │ │87 try issue_id_set.put(&order_key); │ │88 } │ │89 } else { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────╮ │65 - // drop it from the ordered set using its recorded creation timestamp │ │90 + // drop it from the ordered sets using its recorded creation timestamp │ │91 if (try event_id_to_issue.getCursor(issue_key)) |existing_cursor| { │ │92 const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); │ │93 const existing_event = try evt.read(@This(), DB, hash_kind, arena, existing_issue);│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 94 + const order_key = evt.orderKey(existing_event.created_ts, event_id); │ │ 95 + │ │ 96 const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "issue-id-set"));│ │ 97 const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); │ │ 71 - const order_key = evt.orderKey(existing_event.created_ts, event_id); │ │ 98 _ = try issue_id_set.remove(&order_key); │ │ 99 + │ │100 + try removeFromTagSets(DB, tag_to_issues, existing_event.tags, &order_key); │ │101 } │ │102 if (!try event_id_to_issue.remove(issue_key)) return error.EventNotFound; │ │103 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │104 } │ │105 │ │106 +// remove an issue's order key from each of its tags' sets, pruning tags whose│ │107 +// set becomes empty. │ │108 +fn removeFromTagSets( │ │109 + comptime DB: type, │ │110 + tag_to_issues: DB.SortedMap(.read_write), │ │111 + tags: []const u8, │ │112 + order_key: []const u8, │ │113 +) !void { │ │114 + var tag_iter = tagIterator(tags); │ │115 + while (tag_iter.next()) |tag| { │ │116 + if (tag.len == 0) continue; │ │117 + const tag_set_cursor = try tag_to_issues.putCursor(tag); │ │118 + const tag_set = try DB.SortedSet(.read_write).init(tag_set_cursor); │ │119 + _ = try tag_set.remove(order_key); │ │120 + if (0 == try tag_set.count()) _ = try tag_to_issues.remove(tag); │ │121 + } │ │122 +} │ │123 + │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯