╭ changed ─────────╮ │src/test/event.zig│ ╰──────────────────╯ ╭─────────────────────────────────────╮ │ 7 const event_id_size: usize = 32; │ │ 8 │ │ 9 const EventData = union(enum) { │ │10 - add_issue: struct { │ │10 + issue: struct { │ │11 title: []const u8, │ │12 description: []const u8, │ │13 tags: []const []const u8,│ ╰─────────────────────────────────────╯ ╭───────────────────────────────────────────────────────╮ │19 data: EventData, │ │20 }; │ │21 │ │22 +fn randomId(random: std.Random) [event_id_size]u8 {│ │23 + var id_bytes: [event_id_size]u8 = undefined; │ │24 + random.bytes(&id_bytes); │ │25 + return id_bytes; │ │26 +} │ │27 + │ │28 test "simple" { │ │29 const io = std.testing.io; │ │30 const allocator = std.testing.allocator; │ ╰───────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │55 // │ │56 // define test events │ │57 // │ │58 + │ │59 + var prng = std.Random.DefaultPrng.init(std.testing.random_seed); │ │60 + │ │61 + const first_event_id = randomId(prng.random()); │ │62 │ │53 - const events_to_process = [_]EventData{ │ │63 + const events_to_process = [_]Event{ │ │64 .{ │ │55 - .add_issue = .{ │ │56 - .title = "Login form clears password on validation error", │ │57 - .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", │ │58 - .tags = &[_][]const u8{ "bug", "priority-high", "ui" }, │ │65 + .id = std.fmt.bytesToHex(first_event_id, .lower), │ │66 + .data = .{ │ │67 + .issue = .{ │ │68 + .title = "Login form clears password on validation error", │ │69 + .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", │ │70 + .tags = &[_][]const u8{ "bug", "priority-high", "ui" }, │ │71 + }, │ │72 }, │ │73 }, │ │74 + // this event edits the previous one because it has the same id │ │75 .{ │ │62 - .add_issue = .{ │ │63 - .title = "Search results ignore archived project filter", │ │64 - .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.", │ │65 - .tags = &[_][]const u8{ "bug", "search", "backend" }, │ │76 + .id = std.fmt.bytesToHex(first_event_id, .lower), │ │77 + .data = .{ │ │78 + .issue = .{ │ │79 + .title = "Login form clears password on validation error", │ │80 + .description = "Submitting an invalid email address resets the password field and removes typed input. Preserve the field value and show an inline validation message.",│ │81 + .tags = &[_][]const u8{ "bug", "priority-high", "ui" }, │ │82 + }, │ │83 }, │ │84 }, │ │85 .{ │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │69 - .add_issue = .{ │ │70 - .title = "Issue list does not persist selected sort order", │ │71 - .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.", │ │72 - .tags = &[_][]const u8{ "enhancement", "frontend", "preferences" }, │ │86 + .id = std.fmt.bytesToHex(randomId(prng.random()), .lower), │ │87 + .data = .{ │ │88 + .issue = .{ │ │89 + .title = "Search results ignore archived project filter", │ │90 + .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.",│ │91 + .tags = &[_][]const u8{ "bug", "search", "backend" }, │ │92 + }, │ │93 }, │ │94 }, │ │95 .{ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 76 - .add_issue = .{ │ │ 77 - .title = "Webhook retries stop after transient timeout", │ │ 78 - .description = "A single gateway timeout marks webhook delivery as failed permanently. Retry transient network errors with exponential backoff.", │ │ 79 - .tags = &[_][]const u8{ "bug", "webhooks", "reliability" }, │ │ 80 - }, │ │ 81 - }, │ │ 82 - .{ │ │ 83 - .add_issue = .{ │ │ 84 - .title = "CSV export omits labels with commas", │ │ 85 - .description = "Exported issue rows drop labels that contain commas instead of escaping them. Quote CSV fields according to RFC 4180.", │ │ 86 - .tags = &[_][]const u8{ "bug", "export", "data-integrity" }, │ │ 96 + .id = std.fmt.bytesToHex(randomId(prng.random()), .lower), │ │ 97 + .data = .{ │ │ 98 + .issue = .{ │ │ 99 + .title = "Issue list does not persist selected sort order", │ │100 + .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.",│ │101 + .tags = &[_][]const u8{ "enhancement", "frontend", "preferences" }, │ │102 + }, │ │103 }, │ │104 }, │ │105 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │110 │ │111 var arena = std.heap.ArenaAllocator.init(allocator); │ │112 defer arena.deinit(); │ │ 97 - │ │ 98 - var prng = std.Random.DefaultPrng.init(std.testing.random_seed); │ │ 99 - var json: std.Io.Writer.Allocating = .init(std.testing.allocator); │ │100 - defer json.deinit(); │ │101 - │ │102 - for (events_to_process) |event_data| { │ │103 - json.clearRetainingCapacity(); │ │113 │ │105 - var id_bytes: [event_id_size]u8 = undefined; │ │106 - prng.random().bytes(&id_bytes); │ │114 + { │ │115 + var json: std.Io.Writer.Allocating = .init(std.testing.allocator); │ │116 + defer json.deinit(); │ │117 │ │108 - const event = Event{ │ │109 - .id = std.fmt.bytesToHex(id_bytes, .lower), │ │110 - .data = event_data, │ │111 - }; │ │118 + for (events_to_process) |event| { │ │119 + json.clearRetainingCapacity(); │ │120 │ │113 - try std.json.Stringify.value(event, .{}, &json.writer); │ │121 + try std.json.Stringify.value(event, .{}, &json.writer); │ │122 │ │115 - // commit the event into a special branch │ │116 - _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, .{ .kind = .head, .name = "haxy/meta" }); │ │123 + // commit the event into a special branch │ │124 + _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, .{ .kind = .head, .name = "haxy/meta" });│ │125 + } │ │126 } │ │127 │ │128 // │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │132 var events: std.ArrayList(Event) = .empty; │ │133 defer events.deinit(allocator); │ │134 │ │126 - var event_strs: std.ArrayList([]const u8) = .empty; │ │127 - defer event_strs.deinit(allocator); │ │135 + { │ │136 + var event_strs: std.ArrayList([]const u8) = .empty; │ │137 + defer event_strs.deinit(allocator); │ │138 │ │129 - const ref_haxy_meta = try repo.readRef(io, .{ .kind = .head, .name = "haxy/meta" }) orelse return error.RefNotFound; │ │130 - var commit_iter = try repo.log(io, allocator, &.{ref_haxy_meta}); │ │131 - defer commit_iter.deinit(); │ │139 + const ref_haxy_meta = try repo.readRef(io, .{ .kind = .head, .name = "haxy/meta" }) orelse return error.RefNotFound;│ │140 + var commit_iter = try repo.log(io, allocator, &.{ref_haxy_meta}); │ │141 + defer commit_iter.deinit(); │ │142 │ │133 - // read the message from each commit │ │134 - while (try commit_iter.next()) |commit_object| { │ │135 - defer commit_object.deinit(); │ │143 + // read the message from each commit │ │144 + while (try commit_iter.next()) |commit_object| { │ │145 + defer commit_object.deinit(); │ │146 │ │137 - try commit_object.object_reader.seekTo(commit_object.content.commit.message_position); │ │138 - const message = try commit_object.object_reader.interface.allocRemaining(arena.allocator(), .unlimited); │ │147 + try commit_object.object_reader.seekTo(commit_object.content.commit.message_position); │ │148 + const message = try commit_object.object_reader.interface.allocRemaining(arena.allocator(), .unlimited); │ │149 │ │140 - try event_strs.append(allocator, message); │ │141 - } │ │150 + try event_strs.append(allocator, message); │ │151 + } │ │152 │ │143 - // parse commit messages as JSON into event values. │ │144 - // add events in reverse order so the earliest event is first. │ │145 - for (0..event_strs.items.len) |i| { │ │146 - const event_str = event_strs.items[event_strs.items.len - i - 1]; │ │147 - const event = try std.json.parseFromSliceLeaky(Event, arena.allocator(), event_str, .{}); │ │148 - try events.append(allocator, event); │ │153 + // parse commit messages as JSON into event values. │ │154 + // add events in reverse order so the earliest event is first. │ │155 + for (0..event_strs.items.len) |i| { │ │156 + const event_str = event_strs.items[event_strs.items.len - i - 1]; │ │157 + const event = try std.json.parseFromSliceLeaky(Event, arena.allocator(), event_str, .{}); │ │158 + try events.append(allocator, event); │ │159 + } │ │160 } │ │161 │ │162 // │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │186 // get the id of the current event as bytes │ │187 var current_event_id_buffer: [event_id_size]u8 = undefined; │ │188 _ = try std.fmt.hexToBytes(&current_event_id_buffer, &event.id); │ │178 - │ │179 - // if this event has already been processed, skip it │ │180 - if (try haxy.getCursor(hash.hashInt(repo_opts.hash, "event-id->views"))) |event_id_to_views_cursor| { │ │181 - const event_id_to_views = try Repo.DB.HashMap(.read_only).init(event_id_to_views_cursor); │ │182 - if (null != try event_id_to_views.getCursor(hash.hashInt(repo_opts.hash, &current_event_id_buffer))) {│ │183 - continue; │ │184 - } │ │185 - } │ │189 │ │190 // map with the views as they appeared when each event was processed. │ │191 // we can use this to see (and revert) the views to any previous state. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │208 const views = try Repo.DB.HashMap(.read_write).init(views_cursor); │ │209 │ │210 switch (event.data) { │ │208 - .add_issue => |data| { │ │209 - const issues_cursor = try views.putCursor(hash.hashInt(repo_opts.hash, "issues")); │ │210 - const issues = try Repo.DB.ArrayList(.read_write).init(issues_cursor); │ │211 + .issue => |data| { │ │212 + const event_id_to_issue_cursor = try views.putCursor(hash.hashInt(repo_opts.hash, "event-id->issue")); │ │213 + const event_id_to_issue = try Repo.DB.HashMap(.read_write).init(event_id_to_issue_cursor); │ │214 │ │212 - const issue_cursor = try issues.appendCursor(); │ │215 + const issue_cursor = try event_id_to_issue.putCursor(hash.hashInt(repo_opts.hash, &current_event_id_buffer));│ │216 const issue = try Repo.DB.HashMap(.read_write).init(issue_cursor); │ │217 │ │215 - try issue.put(hash.hashInt(repo_opts.hash, "id"), .{ .bytes = &event.id }); │ │216 - try issue.put(hash.hashInt(repo_opts.hash, "title"), .{ .bytes = data.title }); │ │217 - try issue.put(hash.hashInt(repo_opts.hash, "description"), .{ .bytes = data.description }); │ │218 + try putIfDifferent(issue, hash.hashInt(repo_opts.hash, "id"), &current_event_id_buffer); │ │219 + try putIfDifferent(issue, hash.hashInt(repo_opts.hash, "title"), data.title); │ │220 + try putIfDifferent(issue, hash.hashInt(repo_opts.hash, "description"), data.description); │ │221 }, │ │222 } │ │223 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │231 │ │232 if (last_event_id_maybe) |*last_event_id| { │ │233 try haxy.put(hash.hashInt(repo_opts.hash, "last-event-id"), .{ .bytes = last_event_id }); │ │234 + } │ │235 + } │ │236 + │ │237 + fn putIfDifferent(map: Repo.DB.HashMap(.read_write), key: hash.HashInt(repo_opts.hash), value: []const u8) !void {│ │238 + if (try map.getCursor(key)) |value_cursor| { │ │239 + var buffer: [4096]u8 = undefined; │ │240 + const existing_value = try value_cursor.readBytes(&buffer); │ │241 + if (std.mem.eql(u8, existing_value, value)) { │ │242 + return; │ │243 + } │ │244 } │ │245 + │ │246 + try map.put(key, .{ .bytes = value }); │ │247 } │ │248 }; │ │249 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯