╭ event.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const evt = @import("../event.zig"); 3 const xit = @import("xit"); 4 const rp = xit.repo; 5 const rf = xit.ref; 6 const hash = xit.hash; 7 8 test "rebase" { 9 const io = std.testing.io; 10 const allocator = std.testing.allocator; 11 const temp_dir_name = "temp-event-rebase"; 12 13 // create the temp dir 14 const cwd = std.Io.Dir.cwd(); 15 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 16 if (temp_dir_or_err) |*temp_dir| { 17 temp_dir.close(io); 18 try cwd.deleteTree(io, temp_dir_name); 19 } else |_| {} 20 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 21 defer cwd.deleteTree(io, temp_dir_name) catch {}; 22 defer temp_dir.close(io); 23 24 const cwd_path = try std.process.currentPathAlloc(io, allocator); 25 defer allocator.free(cwd_path); 26 27 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 28 defer allocator.free(work_path); 29 30 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 31 const Repo = rp.Repo(.xit, repo_opts); 32 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 33 defer repo.deinit(io, allocator); 34 35 var arena = std.heap.ArenaAllocator.init(allocator); 36 defer arena.deinit(); 37 38 // 39 // define test events 40 // 41 42 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 43 44 const first_event_id = evt.EventWithId.randomId(prng.random()); 45 46 const events_to_consume = [_]evt.EventWithId{ 47 .{ 48 .id = std.fmt.bytesToHex(first_event_id, .lower), 49 .event = .{ 50 .issue = .{ 51 .title = "Login form clears password on validation error", 52 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 53 .tags = "bug priority-high ui", 54 }, 55 }, 56 }, 57 // this event edits the previous one because it has the same id 58 .{ 59 .id = std.fmt.bytesToHex(first_event_id, .lower), 60 .event = .{ 61 .issue = .{ 62 .title = "Login form clears password on validation error", 63 .description = "Submitting an invalid email address resets the password field and removes typed input. Preserve the field value and show an inline validation message.", 64 .tags = "bug priority-low ui", 65 }, 66 }, 67 }, 68 .{ 69 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 70 .event = .{ 71 .issue = .{ 72 .title = "Search results ignore archived project filter", 73 .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.", 74 .tags = "bug search backend", 75 }, 76 }, 77 }, 78 .{ 79 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 80 .event = .{ 81 .issue = .{ 82 .title = "Issue list does not persist selected sort order", 83 .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.", 84 .tags = "enhancement frontend preferences", 85 }, 86 }, 87 }, 88 }; 89 90 // 91 // insert issues as commits in the repo 92 // 93 94 var first_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 95 96 { 97 var json: std.Io.Writer.Allocating = .init(allocator); 98 defer json.deinit(); 99 100 for (events_to_consume, 0..) |event, i| { 101 json.clearRetainingCapacity(); 102 103 try std.json.Stringify.value(event, .{}, &json.writer); 104 105 // commit the event into a special branch 106 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 107 if (i == 0) { 108 _ = try std.fmt.hexToBytes(&first_oid, &oid); 109 } 110 } 111 } 112 113 // 114 // consume events into the database 115 // 116 117 { 118 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 119 120 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 121 122 // get the map of issues 123 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 124 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 125 126 // get the issue out of the map that was edited 127 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id)) orelse return error.NotFound; 128 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 129 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 130 131 // the description was correctly edited 132 try std.testing.expectEqualStrings(events_to_consume[1].event.issue.?.description, first_issue.description); 133 134 // the tags were correctly edited 135 try std.testing.expectEqualStrings(events_to_consume[1].event.issue.?.tags, first_issue.tags); 136 } 137 138 // 139 // add another event 140 // 141 142 const events_to_consume2 = [_]evt.EventWithId{ 143 .{ 144 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 145 .event = .{ 146 .issue = .{ 147 .title = "Double clicking causes the form to submit twice", 148 .description = "When I double click the post button I see duplicate posts.", 149 .tags = "bug priority-high ui", 150 }, 151 }, 152 }, 153 }; 154 155 // commit and consume the new event 156 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume2); 157 158 // 159 // rebase the branch so it no longer includes the edit event 160 // 161 162 const events_to_consume3 = [_]evt.EventWithId{ 163 events_to_consume[2], 164 events_to_consume[3], 165 }; 166 167 { 168 var json: std.Io.Writer.Allocating = .init(allocator); 169 defer json.deinit(); 170 171 for (events_to_consume3, 0..) |event, i| { 172 json.clearRetainingCapacity(); 173 174 try std.json.Stringify.value(event, .{}, &json.writer); 175 176 // commit the event into a special branch 177 _ = try repo.commitAtRef( 178 io, 179 allocator, 180 .{ 181 .message = json.written(), 182 // the first event should have the first oid as its parent, 183 // because we want to keep the very first commit. 184 // and the rest will have null which causes them 185 // to have the previous commit as their parent 186 .parent_oids = if (i == 0) &.{std.fmt.bytesToHex(first_oid, .lower)} else null, 187 }, 188 null, 189 evt.events_ref, 190 ); 191 } 192 } 193 194 // 195 // consume events into the database 196 // 197 198 { 199 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 200 201 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 202 203 // get the map of issues 204 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 205 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 206 207 // get the issue out of the map that was edited 208 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id)) orelse return error.NotFound; 209 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 210 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 211 212 // the description is no longer edited 213 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.description); 214 215 // the tags are no longer edited 216 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.tags); 217 218 // an event added by the second push is no longer there because it was wiped out by the rebase 219 try std.testing.expect(null == try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &events_to_consume2[0].id))); 220 } 221 222 // 223 // rebase the branch so it no longer includes the original issue 224 // 225 226 { 227 var json: std.Io.Writer.Allocating = .init(allocator); 228 defer json.deinit(); 229 230 for (events_to_consume3, 0..) |event, i| { 231 json.clearRetainingCapacity(); 232 233 try std.json.Stringify.value(event, .{}, &json.writer); 234 235 // commit the event into a special branch 236 _ = try repo.commitAtRef( 237 io, 238 allocator, 239 .{ 240 .message = json.written(), 241 // the first event should have no parents, 242 // because we want to drop the very first commit. 243 // and the rest will have null which causes them 244 // to have the previous commit as their parent 245 .parent_oids = if (i == 0) &.{} else null, 246 }, 247 null, 248 evt.events_ref, 249 ); 250 } 251 } 252 253 // 254 // consume events into the database 255 // 256 257 { 258 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 259 260 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 261 262 // get the map of issues 263 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 264 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 265 266 // the first issue no longer exists 267 try std.testing.expect(null == try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id))); 268 } 269 } 270 271 test "merge" { 272 const io = std.testing.io; 273 const allocator = std.testing.allocator; 274 const temp_dir_name = "temp-event-merge"; 275 276 const other_events_ref: rf.Ref = .{ .kind = .head, .name = "haxy/other" }; 277 278 // create the temp dir 279 const cwd = std.Io.Dir.cwd(); 280 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 281 if (temp_dir_or_err) |*temp_dir| { 282 temp_dir.close(io); 283 try cwd.deleteTree(io, temp_dir_name); 284 } else |_| {} 285 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 286 defer cwd.deleteTree(io, temp_dir_name) catch {}; 287 defer temp_dir.close(io); 288 289 const cwd_path = try std.process.currentPathAlloc(io, allocator); 290 defer allocator.free(cwd_path); 291 292 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 293 defer allocator.free(work_path); 294 295 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 296 const Repo = rp.Repo(.xit, repo_opts); 297 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 298 defer repo.deinit(io, allocator); 299 300 var arena = std.heap.ArenaAllocator.init(allocator); 301 defer arena.deinit(); 302 303 // 304 // define test events 305 // 306 307 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 308 309 const events_to_consume = [_]evt.EventWithId{ 310 .{ 311 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 312 .event = .{ 313 .issue = .{ 314 .title = "Login form clears password on validation error", 315 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 316 .tags = "bug priority-high ui", 317 }, 318 }, 319 }, 320 .{ 321 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 322 .event = .{ 323 .issue = .{ 324 .title = "Search results ignore archived project filter", 325 .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.", 326 .tags = "bug search backend", 327 }, 328 }, 329 }, 330 .{ 331 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 332 .event = .{ 333 .issue = .{ 334 .title = "Issue list does not persist selected sort order", 335 .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.", 336 .tags = "enhancement frontend preferences", 337 }, 338 }, 339 }, 340 }; 341 342 // 343 // insert issues as commits in the repo 344 // 345 346 var first_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 347 348 { 349 var json: std.Io.Writer.Allocating = .init(allocator); 350 defer json.deinit(); 351 352 for (events_to_consume, 0..) |event, i| { 353 json.clearRetainingCapacity(); 354 355 try std.json.Stringify.value(event, .{}, &json.writer); 356 357 // commit the event into a special branch 358 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 359 if (i == 0) { 360 _ = try std.fmt.hexToBytes(&first_oid, &oid); 361 } 362 } 363 } 364 365 // 366 // consume events into the database 367 // 368 369 { 370 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 371 372 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 373 374 // get the map of issues 375 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 376 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 377 378 // get the issue out of the map 379 var first_issue_id: [evt.event_id_size]u8 = undefined; 380 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume[0].id); 381 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 382 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 383 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 384 385 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.description); 386 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.tags); 387 } 388 389 // 390 // define more test events 391 // 392 393 const events_to_consume2 = [_]evt.EventWithId{ 394 .{ 395 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 396 .event = .{ 397 .issue = .{ 398 .title = "Kanban card status badge falls behind after drag", 399 .description = "Moving an issue between columns updates the card position immediately, but the status badge keeps showing the previous state until refresh.", 400 .tags = "bug kanban frontend", 401 }, 402 }, 403 }, 404 .{ 405 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 406 .event = .{ 407 .issue = .{ 408 .title = "Assignee autocomplete omits recently invited users", 409 .description = "Users invited during the current session do not appear in the assignee picker until the project page is reloaded.", 410 .tags = "bug assignees api", 411 }, 412 }, 413 }, 414 .{ 415 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 416 .event = .{ 417 .issue = .{ 418 .title = "Add due date warning for issues blocked by dependencies", 419 .description = "Show a warning when an issue's due date is earlier than an unresolved blocking issue so planners can adjust the schedule.", 420 .tags = "enhancement planning dependencies", 421 }, 422 }, 423 }, 424 }; 425 426 // 427 // insert issues as commits in the repo 428 // 429 430 { 431 var json: std.Io.Writer.Allocating = .init(allocator); 432 defer json.deinit(); 433 434 for (events_to_consume2, 0..) |event, i| { 435 json.clearRetainingCapacity(); 436 437 try std.json.Stringify.value(event, .{}, &json.writer); 438 439 // commit the event into a special branch 440 _ = try repo.commitAtRef( 441 io, 442 allocator, 443 // make the first commit a child of the first commit on the events branch 444 .{ .parent_oids = if (i == 0) &.{std.fmt.bytesToHex(first_oid, .lower)} else null, .message = json.written() }, 445 null, 446 other_events_ref, 447 ); 448 } 449 } 450 451 // 452 // check out the events branch and merge the other events branch into it 453 // 454 455 const merge_oid = blk: { 456 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 457 defer result.deinit(); 458 459 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 460 defer merge.deinit(); 461 462 try std.testing.expect(.success == merge.result); 463 464 break :blk merge.result.success.oid; 465 }; 466 467 // 468 // consume events into the database 469 // 470 471 { 472 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 473 474 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 475 476 // get the map of issues 477 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 478 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 479 480 // make sure one of the original issues is still there 481 { 482 // get the issue out of the map 483 var first_issue_id: [evt.event_id_size]u8 = undefined; 484 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume[0].id); 485 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 486 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 487 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 488 489 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.description); 490 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.tags); 491 } 492 493 // make sure one of the new issues is there 494 { 495 // get the issue out of the map 496 var first_issue_id: [evt.event_id_size]u8 = undefined; 497 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 498 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 499 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 500 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 501 502 try std.testing.expectEqualStrings(events_to_consume2[0].event.issue.?.description, first_issue.description); 503 try std.testing.expectEqualStrings(events_to_consume2[0].event.issue.?.tags, first_issue.tags); 504 } 505 506 // the ordered issue set unions both branches' additions 507 { 508 const status_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "status->issue-id-set")) orelse return error.NotFound; 509 const status_to_issues = try Repo.DB.SortedMap(.read_only).init(status_to_issues_cursor); 510 const issue_id_set_cursor = try status_to_issues.getCursor("open") orelse return error.NotFound; 511 const issue_id_set = try Repo.DB.SortedSet(.read_only).init(issue_id_set_cursor); 512 try std.testing.expectEqual(6, try issue_id_set.count()); 513 514 var found = [_]bool{false} ** 6; 515 var iter = try issue_id_set.iteratorFromIndex(0); 516 while (try iter.next()) |id_cursor_val| { 517 var id_cursor = id_cursor_val; 518 const id_kv = try id_cursor.readKeyValuePair(); 519 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 520 _ = try id_kv.key_cursor.readBytes(&order_key); 521 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 522 for (events_to_consume, 0..) |event, i| { 523 if (std.mem.eql(u8, &id_hex, &event.id)) found[i] = true; 524 } 525 for (events_to_consume2, 0..) |event, i| { 526 if (std.mem.eql(u8, &id_hex, &event.id)) found[events_to_consume.len + i] = true; 527 } 528 } 529 for (found) |f| try std.testing.expect(f); 530 } 531 } 532 533 // 534 // insert issues as commits in the repo 535 // 536 537 { 538 var json: std.Io.Writer.Allocating = .init(allocator); 539 defer json.deinit(); 540 541 for (events_to_consume, 0..) |event, i| { 542 json.clearRetainingCapacity(); 543 544 try std.json.Stringify.value(event, .{}, &json.writer); 545 546 // commit the event into a special branch 547 _ = try repo.commitAtRef( 548 io, 549 allocator, 550 // make the first event have no parents so we completely rebase the branch, 551 // undoing the merge that we did above 552 .{ .parent_oids = if (i == 0) &.{} else null, .message = json.written() }, 553 null, 554 evt.events_ref, 555 ); 556 } 557 } 558 559 // 560 // consume events into the database 561 // 562 563 { 564 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 565 566 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 567 568 // get the map of issues 569 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 570 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 571 572 // make sure the new issue is not there 573 { 574 // get the issue out of the map 575 var first_issue_id: [evt.event_id_size]u8 = undefined; 576 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 577 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)); 578 try std.testing.expect(null == first_issue_cursor); 579 } 580 } 581 582 // 583 // define test events that modify the same issue in conflicting ways 584 // 585 586 const events_to_consume3 = [_]evt.EventWithId{ 587 .{ 588 .id = events_to_consume[0].id, 589 .event = .{ 590 .issue = .{ 591 .title = "Login form clears password on validation error", 592 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 593 .tags = "bug priority-low ui", 594 }, 595 }, 596 }, 597 .{ 598 .id = events_to_consume[0].id, 599 .event = .{ 600 .issue = .{ 601 .title = "Login form clears password on validation error", 602 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 603 .tags = "bug priority-medium ui", 604 }, 605 }, 606 }, 607 }; 608 609 // 610 // insert issues as commits in the repo on different branches 611 // 612 613 var conflicting_oids: [events_to_consume3.len][hash.byteLen(repo_opts.hash)]u8 = undefined; 614 615 { 616 var json: std.Io.Writer.Allocating = .init(allocator); 617 defer json.deinit(); 618 619 for (events_to_consume3, 0..) |event, i| { 620 json.clearRetainingCapacity(); 621 622 try std.json.Stringify.value(event, .{}, &json.writer); 623 624 // commit the event into a special branch 625 const oid = try repo.commitAtRef( 626 io, 627 allocator, 628 .{ .parent_oids = &.{merge_oid}, .message = json.written() }, 629 null, 630 switch (i) { 631 0 => evt.events_ref, 632 1 => other_events_ref, 633 else => unreachable, 634 }, 635 ); 636 _ = try std.fmt.hexToBytes(&conflicting_oids[i], &oid); 637 } 638 } 639 640 // 641 // check out the events branch and merge the other events branch into it 642 // 643 644 const conflicting_merge_oid = blk: { 645 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 646 defer result.deinit(); 647 648 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 649 defer merge.deinit(); 650 651 try std.testing.expect(.success == merge.result); 652 653 break :blk merge.result.success.oid; 654 }; 655 656 // 657 // consume events into the database 658 // 659 660 var issue_id: [evt.event_id_size]u8 = undefined; 661 _ = try std.fmt.hexToBytes(&issue_id, &events_to_consume[0].id); 662 663 { 664 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 665 666 // consuming the merge again must be a no-op rather than replaying it 667 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 668 669 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 670 671 // the first parent is the events branch, so its version stays live 672 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 673 try std.testing.expectEqualStrings("bug priority-low ui", issue.tags); 674 675 // the issue is listed as conflicted, with the merge base recorded 676 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 677 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 678 try std.testing.expectEqual(1, try conflicts.count()); 679 680 const order_key = evt.orderKeyDesc(issue.created_ts, &issue_id); 681 const conflict_cursor = try conflicts.getCursor(&order_key) orelse return error.NotFound; 682 const conflict = try Repo.DB.HashMap(.read_only).init(conflict_cursor); 683 684 const base_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.base_record_key)) orelse return error.NotFound; 685 const base = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, try Repo.DB.HashMap(.read_only).init(base_cursor)); 686 try std.testing.expectEqualStrings("bug priority-high ui", base.tags); 687 688 // tags is the one field both sides changed differently 689 const fields_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key)) orelse return error.NotFound; 690 const conflicted_fields = try fields_cursor.readBytesAlloc(arena.allocator(), null); 691 try std.testing.expectEqualStrings("tags", conflicted_fields); 692 693 // their whole version is recorded, not just the conflicting field 694 const their_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key)) orelse return error.NotFound; 695 const theirs = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, try Repo.DB.HashMap(.read_only).init(their_cursor)); 696 try std.testing.expectEqualStrings("bug priority-medium ui", theirs.tags); 697 try std.testing.expectEqualStrings(issue.title, theirs.title); 698 try std.testing.expectEqualStrings(issue.description, theirs.description); 699 700 // their value is attributed to the commit that set it on their branch 701 const oids_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.their_field_to_oid_key)) orelse return error.NotFound; 702 const oids = try Repo.DB.SortedMap(.read_only).init(oids_cursor); 703 const oid_cursor = try oids.getCursor("tags") orelse return error.NotFound; 704 var their_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 705 _ = try oid_cursor.readBytes(&their_oid); 706 try std.testing.expectEqualSlices(u8, &conflicting_oids[1], &their_oid); 707 708 // ours stays attributed to the commit on the events branch 709 try std.testing.expectEqualSlices(u8, &conflicting_oids[0], &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &issue_id, "tags")); 710 711 // the losing side's tag didn't leak into the tag index 712 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 713 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 714 try std.testing.expect(null != try tag_to_issues.getCursor("priority-low open")); 715 try std.testing.expect(null == try tag_to_issues.getCursor("priority-medium open")); 716 } 717 718 // 719 // a merge that touches the issue without conflicting leaves the conflict 720 // alone, since only events settle one 721 // 722 723 { 724 const their_issue = events_to_consume3[1].event.issue orelse return error.NotFound; 725 726 var json: std.Io.Writer.Allocating = .init(allocator); 727 defer json.deinit(); 728 729 // the other branch never saw the conflict and rewrites an unrelated 730 // field 731 const event = evt.EventWithId{ .id = events_to_consume3[1].id, .event = .{ .issue = .{ 732 .title = their_issue.title, 733 .description = "Rewritten on a branch that never merged.", 734 .tags = their_issue.tags, 735 } } }; 736 try std.json.Stringify.value(event, .{}, &json.writer); 737 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 738 739 { 740 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 741 defer result.deinit(); 742 743 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 744 defer merge.deinit(); 745 746 try std.testing.expect(.success == merge.result); 747 } 748 749 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 750 751 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 752 753 // their edit came over, so the merge really did touch the issue 754 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 755 try std.testing.expectEqualStrings("Rewritten on a branch that never merged.", issue.description); 756 757 // and the conflict is still waiting to be resolved 758 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 759 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 760 try std.testing.expectEqual(1, try conflicts.count()); 761 } 762 763 // 764 // any event settles the conflict, even one that changes no conflicting 765 // field 766 // 767 768 { 769 try evt.Issue.update(.xit, repo_opts, io, allocator, &repo, &issue_id, .{ .status = .closed }); 770 771 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 772 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 773 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 774 try std.testing.expectEqual(0, try conflicts.count()); 775 } 776 777 // 778 // edit the issue, so the merge below has a resolution to preserve 779 // 780 781 { 782 try evt.Issue.update(.xit, repo_opts, io, allocator, &repo, &issue_id, .{ .fields = .{ 783 .title = "Login form clears password on validation error", 784 .tags = "bug priority-medium ui", 785 .description = "Submitting an invalid email address resets the password field.", 786 } }); 787 } 788 789 // 790 // merging a branch that still holds the settled conflict must not bring it 791 // back 792 // 793 794 { 795 const conflicted_issue = events_to_consume3[0].event.issue orelse return error.NotFound; 796 797 var json: std.Io.Writer.Allocating = .init(allocator); 798 defer json.deinit(); 799 800 // the other branch changes the record on a commit that predates the 801 // resolution, so it still carries the conflict 802 const event = evt.EventWithId{ .id = events_to_consume3[0].id, .event = .{ .issue = .{ 803 .title = conflicted_issue.title, 804 .description = conflicted_issue.description, 805 .tags = conflicted_issue.tags, 806 .status = .closed, 807 } } }; 808 try std.json.Stringify.value(event, .{}, &json.writer); 809 _ = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{conflicting_merge_oid}, .message = json.written() }, null, other_events_ref); 810 811 { 812 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 813 defer result.deinit(); 814 815 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 816 defer merge.deinit(); 817 818 try std.testing.expect(.success == merge.result); 819 } 820 821 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 822 823 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 824 825 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 826 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 827 try std.testing.expectEqual(0, try conflicts.count()); 828 829 // and the resolution stands 830 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 831 try std.testing.expectEqualStrings("bug priority-medium ui", issue.tags); 832 } 833 834 // 835 // diverge again, with each side editing different fields of one issue and 836 // only the other side touching a second issue 837 // 838 839 const second_issue = events_to_consume[1].event.issue orelse return error.NotFound; 840 const third_issue = events_to_consume[2].event.issue orelse return error.NotFound; 841 842 var retitle_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 843 var rewrite_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 844 845 { 846 const base_oid = try repo.readRef(io, evt.events_ref) orelse return error.NotFound; 847 848 var json: std.Io.Writer.Allocating = .init(allocator); 849 defer json.deinit(); 850 851 // the events branch only retitles the second issue 852 { 853 const event = evt.EventWithId{ .id = events_to_consume[1].id, .event = .{ .issue = .{ 854 .title = "Search results ignore the archived project filter", 855 .description = second_issue.description, 856 .tags = second_issue.tags, 857 } } }; 858 try std.json.Stringify.value(event, .{}, &json.writer); 859 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 860 _ = try std.fmt.hexToBytes(&retitle_oid, &oid); 861 } 862 863 // the other branch rewrites the second issue's description and drops one 864 // of its tags, then retags the third issue 865 { 866 json.clearRetainingCapacity(); 867 const event = evt.EventWithId{ .id = events_to_consume[1].id, .event = .{ .issue = .{ 868 .title = second_issue.title, 869 .description = "Archived projects are ranked before the flag is applied.", 870 .tags = "bug search", 871 } } }; 872 try std.json.Stringify.value(event, .{}, &json.writer); 873 const oid = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{base_oid}, .message = json.written() }, null, other_events_ref); 874 _ = try std.fmt.hexToBytes(&rewrite_oid, &oid); 875 } 876 { 877 json.clearRetainingCapacity(); 878 const event = evt.EventWithId{ .id = events_to_consume[2].id, .event = .{ .issue = .{ 879 .title = third_issue.title, 880 .description = third_issue.description, 881 .tags = "enhancement roadmap", 882 } } }; 883 try std.json.Stringify.value(event, .{}, &json.writer); 884 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 885 } 886 } 887 888 { 889 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 890 defer result.deinit(); 891 892 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 893 defer merge.deinit(); 894 895 try std.testing.expect(.success == merge.result); 896 } 897 898 { 899 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 900 901 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 902 903 var second_id: [evt.event_id_size]u8 = undefined; 904 _ = try std.fmt.hexToBytes(&second_id, &events_to_consume[1].id); 905 906 // each side's edit to a different field survives, without conflicting 907 const merged = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &second_id); 908 try std.testing.expectEqualStrings("Search results ignore the archived project filter", merged.title); 909 try std.testing.expectEqualStrings("Archived projects are ranked before the flag is applied.", merged.description); 910 try std.testing.expectEqualStrings("bug search", merged.tags); 911 912 // each merged field is attributed to the branch it came from 913 try std.testing.expectEqualSlices(u8, &retitle_oid, &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &second_id, "title")); 914 try std.testing.expectEqualSlices(u8, &rewrite_oid, &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &second_id, "description")); 915 916 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 917 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 918 try std.testing.expectEqual(0, try conflicts.count()); 919 920 // tags the merged-in side dropped don't survive in the tag index, 921 // whether we edited that issue or left it alone 922 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 923 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 924 925 var third_id: [evt.event_id_size]u8 = undefined; 926 _ = try std.fmt.hexToBytes(&third_id, &events_to_consume[2].id); 927 const retagged = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &third_id); 928 try std.testing.expectEqualStrings("enhancement roadmap", retagged.tags); 929 930 try std.testing.expect(null == try tag_to_issues.getCursor("backend open")); 931 try std.testing.expect(null == try tag_to_issues.getCursor("preferences open")); 932 try std.testing.expect(null != try tag_to_issues.getCursor("roadmap open")); 933 try std.testing.expect(null != try tag_to_issues.getCursor("enhancement open")); 934 } 935 936 // 937 // a deletion on the other branch carries over, unless we edited the issue, 938 // in which case the edit wins 939 // 940 941 { 942 const edited_issue = events_to_consume2[1].event.issue orelse return error.NotFound; 943 944 const base_oid = try repo.readRef(io, evt.events_ref) orelse return error.NotFound; 945 946 var json: std.Io.Writer.Allocating = .init(allocator); 947 defer json.deinit(); 948 949 // we retitle one of the two the other branch is about to drop 950 { 951 const event = evt.EventWithId{ .id = events_to_consume2[1].id, .event = .{ .issue = .{ 952 .title = "Kept by the edit", 953 .description = edited_issue.description, 954 .tags = edited_issue.tags, 955 } } }; 956 try std.json.Stringify.value(event, .{}, &json.writer); 957 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 958 } 959 960 // the other branch deletes both 961 { 962 json.clearRetainingCapacity(); 963 const event = evt.EventWithId{ .id = events_to_consume2[0].id, .event = .{ .issue = null } }; 964 try std.json.Stringify.value(event, .{}, &json.writer); 965 _ = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{base_oid}, .message = json.written() }, null, other_events_ref); 966 } 967 { 968 json.clearRetainingCapacity(); 969 const event = evt.EventWithId{ .id = events_to_consume2[1].id, .event = .{ .issue = null } }; 970 try std.json.Stringify.value(event, .{}, &json.writer); 971 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 972 } 973 974 { 975 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 976 defer result.deinit(); 977 978 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 979 defer merge.deinit(); 980 981 try std.testing.expect(.success == merge.result); 982 } 983 984 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 985 986 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 987 988 // the one we left alone is gone, and its tags left the index with it 989 var dropped_id: [evt.event_id_size]u8 = undefined; 990 _ = try std.fmt.hexToBytes(&dropped_id, &events_to_consume2[0].id); 991 try std.testing.expectError(error.NotFound, readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &dropped_id)); 992 993 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 994 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 995 try std.testing.expect(null == try tag_to_issues.getCursor("kanban open")); 996 997 // the one we edited survives, with our edit 998 var kept_id: [evt.event_id_size]u8 = undefined; 999 _ = try std.fmt.hexToBytes(&kept_id, &events_to_consume2[1].id); 1000 const kept = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &kept_id); 1001 try std.testing.expectEqualStrings("Kept by the edit", kept.title); 1002 } 1003 } 1004 1005 // the commit that last set `field_name` on the issue `id` names 1006 fn readOid( 1007 comptime DB: type, 1008 comptime hash_kind: hash.HashKind, 1009 haxy_moment: DB.HashMap(.read_only), 1010 id: *const [evt.event_id_size]u8, 1011 comptime field_name: []const u8, 1012 ) ![hash.byteLen(hash_kind)]u8 { 1013 const id_to_field_to_oid_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.Issue.id_to_field_to_oid_key)) orelse return error.NotFound; 1014 const id_to_field_to_oid = try DB.HashMap(.read_only).init(id_to_field_to_oid_cursor); 1015 const field_oids_cursor = try id_to_field_to_oid.getCursor(hash.hashInt(hash_kind, id)) orelse return error.NotFound; 1016 const field_oids = try DB.SortedMap(.read_only).init(field_oids_cursor); 1017 const cursor = try field_oids.getCursor(field_name) orelse return error.NotFound; 1018 var oid: [hash.byteLen(hash_kind)]u8 = undefined; 1019 _ = try cursor.readBytes(&oid); 1020 return oid; 1021 } 1022 1023 // the issue `id` names, read out of `event-id->issue` 1024 fn readIssue( 1025 comptime DB: type, 1026 comptime hash_kind: hash.HashKind, 1027 haxy_moment: DB.HashMap(.read_only), 1028 arena: *std.heap.ArenaAllocator, 1029 id: *const [evt.event_id_size]u8, 1030 ) !evt.Issue { 1031 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->issue")) orelse return error.NotFound; 1032 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 1033 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(hash_kind, id)) orelse return error.NotFound; 1034 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 1035 return try evt.read(evt.Issue, DB, hash_kind, arena, issue_map); 1036 } 1037 1038 test "user and repo" { 1039 const io = std.testing.io; 1040 const allocator = std.testing.allocator; 1041 const temp_dir_name = "temp-event-user-and-repo"; 1042 1043 // create the temp dir 1044 const cwd = std.Io.Dir.cwd(); 1045 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 1046 if (temp_dir_or_err) |*temp_dir| { 1047 temp_dir.close(io); 1048 try cwd.deleteTree(io, temp_dir_name); 1049 } else |_| {} 1050 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 1051 defer cwd.deleteTree(io, temp_dir_name) catch {}; 1052 defer temp_dir.close(io); 1053 1054 const cwd_path = try std.process.currentPathAlloc(io, allocator); 1055 defer allocator.free(cwd_path); 1056 1057 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 1058 defer allocator.free(work_path); 1059 1060 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 1061 const Repo = rp.Repo(.xit, repo_opts); 1062 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 1063 defer repo.deinit(io, allocator); 1064 1065 var arena = std.heap.ArenaAllocator.init(allocator); 1066 defer arena.deinit(); 1067 1068 // 1069 // define test events 1070 // 1071 1072 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 1073 const user_event_id = evt.EventWithId.randomId(prng.random()); 1074 const repo_event_id = evt.EventWithId.randomId(prng.random()); 1075 1076 var first_password_hash_buf: [evt.User.password_hash_max_len]u8 = undefined; 1077 const first_password_hash = try evt.User.hashPassword("correct horse battery staple", &first_password_hash_buf, io); 1078 1079 var second_password_hash_buf: [evt.User.password_hash_max_len]u8 = undefined; 1080 const second_password_hash = try evt.User.hashPassword("Tr0ub4dor&3", &second_password_hash_buf, io); 1081 1082 const events_to_consume = [_]evt.EventWithId{ 1083 .{ 1084 .id = std.fmt.bytesToHex(user_event_id, .lower), 1085 .event = .{ 1086 .user = .{ 1087 .name = "alice", 1088 .display_name = "Alice Example", 1089 .email = "alice@example.test", 1090 .password_hash = first_password_hash, 1091 }, 1092 }, 1093 }, 1094 // this event edits the previous one because it has the same id 1095 .{ 1096 .id = std.fmt.bytesToHex(user_event_id, .lower), 1097 .event = .{ 1098 .user = .{ 1099 .name = "alice", 1100 .display_name = "Alice Example", 1101 .email = "alice@example.test", 1102 .password_hash = second_password_hash, 1103 }, 1104 }, 1105 }, 1106 .{ 1107 .id = std.fmt.bytesToHex(repo_event_id, .lower), 1108 .event = .{ 1109 .repo = .{ 1110 .user_id = &user_event_id, 1111 .name = "ziglings", 1112 .description = "Learn the Zig programming language by fixing tiny broken programs", 1113 }, 1114 }, 1115 }, 1116 }; 1117 1118 // commit and consume the seed events 1119 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume); 1120 1121 { 1122 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 1123 1124 // get the map of users 1125 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->user")) orelse return error.NotFound; 1126 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); 1127 1128 // get the user out of the map that was edited 1129 const user_cursor = try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 1130 const user_map = try Repo.DB.HashMap(.read_only).init(user_cursor); 1131 const user_event = try evt.read(evt.User, Repo.DB, repo_opts.hash, &arena, user_map); 1132 1133 // the password was correctly edited 1134 try std.testing.expectEqualStrings(events_to_consume[1].event.user.?.password_hash, user_event.password_hash); 1135 1136 // get the map of repos 1137 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 1138 const event_id_to_repo = try Repo.DB.HashMap(.read_only).init(event_id_to_repo_cursor); 1139 1140 // get the repo out of the map 1141 const repo_cursor = try event_id_to_repo.getCursor(hash.hashInt(repo_opts.hash, &repo_event_id)) orelse return error.NotFound; 1142 const repo_map = try Repo.DB.HashMap(.read_only).init(repo_cursor); 1143 const repo_event = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, repo_map); 1144 1145 try std.testing.expectEqualSlices(u8, &user_event_id, repo_event.user_id); 1146 try std.testing.expectEqualStrings("ziglings", repo_event.name); 1147 1148 // get the repos created by the user 1149 const user_id_to_repo_id_set_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "user-id->repo-id-set")) orelse return error.NotFound; 1150 const user_id_to_repo_id_set = try Repo.DB.HashMap(.read_only).init(user_id_to_repo_id_set_cursor); 1151 1152 const user_repos_cursor = try user_id_to_repo_id_set.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 1153 const user_repos = try Repo.DB.SortedSet(.read_only).init(user_repos_cursor); 1154 1155 try std.testing.expectEqual(1, try user_repos.count()); 1156 1157 // the set is keyed by orderKeyDesc([created-ts][event-id]) 1158 const order_key = evt.orderKeyDesc(repo_event.created_ts, &repo_event_id); 1159 try std.testing.expect(try user_repos.contains(&order_key)); 1160 } 1161 1162 // 1163 // remove the repo 1164 // 1165 1166 const events_to_consume2 = [_]evt.EventWithId{ 1167 .{ 1168 .id = std.fmt.bytesToHex(repo_event_id, .lower), 1169 .event = .{ .repo = null }, 1170 }, 1171 }; 1172 1173 // commit and consume the removal 1174 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume2); 1175 1176 { 1177 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 1178 1179 // get the map of repos 1180 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 1181 const event_id_to_repo = try Repo.DB.HashMap(.read_only).init(event_id_to_repo_cursor); 1182 1183 try std.testing.expect(null == try event_id_to_repo.getCursor(hash.hashInt(repo_opts.hash, &repo_event_id))); 1184 1185 // get the repos created by the user 1186 const user_id_to_repo_id_set_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "user-id->repo-id-set")) orelse return error.NotFound; 1187 const user_id_to_repo_id_set = try Repo.DB.HashMap(.read_only).init(user_id_to_repo_id_set_cursor); 1188 1189 const user_repos_cursor = try user_id_to_repo_id_set.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 1190 const user_repos = try Repo.DB.SortedSet(.read_only).init(user_repos_cursor); 1191 1192 // removing the repo emptied the user's set 1193 try std.testing.expectEqual(0, try user_repos.count()); 1194 } 1195 1196 // 1197 // remove the user 1198 // 1199 1200 const events_to_consume3 = [_]evt.EventWithId{ 1201 .{ 1202 .id = std.fmt.bytesToHex(user_event_id, .lower), 1203 .event = .{ .user = null }, 1204 }, 1205 }; 1206 1207 // commit and consume the removal 1208 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume3); 1209 1210 { 1211 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 1212 1213 // get the map of users 1214 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->user")) orelse return error.NotFound; 1215 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); 1216 1217 try std.testing.expect(null == try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id))); 1218 } 1219 } 1220 1221 test "repos and users paginate newest first" { 1222 const io = std.testing.io; 1223 const allocator = std.testing.allocator; 1224 const temp_dir_name = "temp-event-order"; 1225 1226 const cwd = std.Io.Dir.cwd(); 1227 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 1228 if (temp_dir_or_err) |*temp_dir| { 1229 temp_dir.close(io); 1230 try cwd.deleteTree(io, temp_dir_name); 1231 } else |_| {} 1232 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 1233 defer cwd.deleteTree(io, temp_dir_name) catch {}; 1234 defer temp_dir.close(io); 1235 1236 const cwd_path = try std.process.currentPathAlloc(io, allocator); 1237 defer allocator.free(cwd_path); 1238 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 1239 defer allocator.free(work_path); 1240 1241 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 1242 const Repo = rp.Repo(.xit, repo_opts); 1243 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 1244 defer repo.deinit(io, allocator); 1245 1246 var arena = std.heap.ArenaAllocator.init(allocator); 1247 defer arena.deinit(); 1248 1249 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 1250 const user_id = evt.EventWithId.randomId(prng.random()); 1251 var repo_ids: [4][evt.event_id_size]u8 = undefined; 1252 for (&repo_ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); 1253 1254 var pw_buf: [evt.User.password_hash_max_len]u8 = undefined; 1255 const pw = try evt.User.hashPassword("pw", &pw_buf, io); 1256 1257 // asserts the repo-id-set holds exactly `expected` ids in that order 1258 const Check = struct { 1259 fn order( 1260 comptime DB: type, 1261 comptime hash_kind: hash.HashKind, 1262 moment: DB.HashMap(.read_only), 1263 expected: []const *const [evt.event_id_size]u8, 1264 ) !void { 1265 const cursor = try moment.getCursor(hash.hashInt(hash_kind, "repo-id-set")) orelse return error.NotFound; 1266 const set = try DB.SortedSet(.read_only).init(cursor); 1267 try std.testing.expectEqual(expected.len, try set.count()); 1268 for (expected, 0..) |id, i| { 1269 const kv = (try set.getIndexKeyValuePair(@intCast(i))) orelse return error.NotFound; 1270 // the key is orderKeyDesc ([timestamp][event-id]); its trailing bytes are the id 1271 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 1272 _ = try kv.key_cursor.readBytes(&order_key); 1273 try std.testing.expectEqualSlices(u8, id, order_key[@sizeOf(u64)..]); 1274 } 1275 } 1276 }; 1277 1278 // one user, then four repos, each with its own creation timestamp (user@100, 1279 // repos@101..104) 1280 const events = [_]evt.EventWithId{ 1281 .{ .id = std.fmt.bytesToHex(user_id, .lower), .timestamp = 100, .event = .{ .user = .{ .name = "alice", .display_name = "Alice", .email = "alice@example.test", .password_hash = pw } } }, 1282 .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 101, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "d0" } } }, 1283 .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 102, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo1", .description = "d1" } } }, 1284 .{ .id = std.fmt.bytesToHex(repo_ids[2], .lower), .timestamp = 103, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo2", .description = "d2" } } }, 1285 .{ .id = std.fmt.bytesToHex(repo_ids[3], .lower), .timestamp = 104, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo3", .description = "d3" } } }, 1286 }; 1287 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events); 1288 1289 { 1290 const moment = try evt.currentMoment(repo_opts, &repo); 1291 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[3], &repo_ids[2], &repo_ids[1], &repo_ids[0] }); 1292 1293 // the single user shows up in its own ordered set 1294 const ucur = try moment.getCursor(hash.hashInt(repo_opts.hash, "user-id-set")) orelse return error.NotFound; 1295 const uset = try Repo.DB.SortedSet(.read_only).init(ucur); 1296 try std.testing.expectEqual(1, try uset.count()); 1297 } 1298 1299 // delete repo1 -> dense order (no tombstone) 1300 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{ 1301 .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 200, .event = .{ .repo = null } }, 1302 }); 1303 { 1304 const moment = try evt.currentMoment(repo_opts, &repo); 1305 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[3], &repo_ids[2], &repo_ids[0] }); 1306 } 1307 1308 // update repo0 at a later timestamp -> keeps its original place 1309 try evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{ 1310 .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 300, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "updated" } } }, 1311 }); 1312 { 1313 const moment = try evt.currentMoment(repo_opts, &repo); 1314 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[3], &repo_ids[2], &repo_ids[0] }); 1315 1316 // the value really was updated 1317 const e2r_cur = try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 1318 const e2r = try Repo.DB.HashMap(.read_only).init(e2r_cur); 1319 const rc = try e2r.getCursor(hash.hashInt(repo_opts.hash, &repo_ids[0])) orelse return error.NotFound; 1320 const rm = try Repo.DB.HashMap(.read_only).init(rc); 1321 const re = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, rm); 1322 try std.testing.expectEqualStrings("updated", re.description); 1323 } 1324 } 1325