╭ 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.commitAndConsume(.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 { 614 var json: std.Io.Writer.Allocating = .init(allocator); 615 defer json.deinit(); 616 617 for (events_to_consume3, 0..) |event, i| { 618 json.clearRetainingCapacity(); 619 620 try std.json.Stringify.value(event, .{}, &json.writer); 621 622 // commit the event into a special branch 623 _ = try repo.commitAtRef( 624 io, 625 allocator, 626 .{ .parent_oids = &.{merge_oid}, .message = json.written() }, 627 null, 628 switch (i) { 629 0 => evt.events_ref, 630 1 => other_events_ref, 631 else => unreachable, 632 }, 633 ); 634 } 635 } 636 637 // 638 // check out the events branch and merge the other events branch into it 639 // 640 641 { 642 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 643 defer result.deinit(); 644 645 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 646 defer merge.deinit(); 647 648 try std.testing.expect(.success == merge.result); 649 } 650 651 // 652 // consume events into the database 653 // 654 655 try std.testing.expectError(error.MergeConflict, evt.consume(.xit, repo_opts, io, allocator, &repo, evt.events_ref)); 656 } 657 658 test "user and repo" { 659 const io = std.testing.io; 660 const allocator = std.testing.allocator; 661 const temp_dir_name = "temp-event-user-and-repo"; 662 663 // create the temp dir 664 const cwd = std.Io.Dir.cwd(); 665 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 666 if (temp_dir_or_err) |*temp_dir| { 667 temp_dir.close(io); 668 try cwd.deleteTree(io, temp_dir_name); 669 } else |_| {} 670 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 671 defer cwd.deleteTree(io, temp_dir_name) catch {}; 672 defer temp_dir.close(io); 673 674 const cwd_path = try std.process.currentPathAlloc(io, allocator); 675 defer allocator.free(cwd_path); 676 677 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 678 defer allocator.free(work_path); 679 680 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 681 const Repo = rp.Repo(.xit, repo_opts); 682 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 683 defer repo.deinit(io, allocator); 684 685 var arena = std.heap.ArenaAllocator.init(allocator); 686 defer arena.deinit(); 687 688 // 689 // define test events 690 // 691 692 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 693 const user_event_id = evt.EventWithId.randomId(prng.random()); 694 const repo_event_id = evt.EventWithId.randomId(prng.random()); 695 696 var first_password_hash_buf: [evt.User.password_hash_max_len]u8 = undefined; 697 const first_password_hash = try evt.User.hashPassword("correct horse battery staple", &first_password_hash_buf, io); 698 699 var second_password_hash_buf: [evt.User.password_hash_max_len]u8 = undefined; 700 const second_password_hash = try evt.User.hashPassword("Tr0ub4dor&3", &second_password_hash_buf, io); 701 702 const events_to_consume = [_]evt.EventWithId{ 703 .{ 704 .id = std.fmt.bytesToHex(user_event_id, .lower), 705 .event = .{ 706 .user = .{ 707 .name = "alice", 708 .display_name = "Alice Example", 709 .email = "alice@example.test", 710 .password_hash = first_password_hash, 711 }, 712 }, 713 }, 714 // this event edits the previous one because it has the same id 715 .{ 716 .id = std.fmt.bytesToHex(user_event_id, .lower), 717 .event = .{ 718 .user = .{ 719 .name = "alice", 720 .display_name = "Alice Example", 721 .email = "alice@example.test", 722 .password_hash = second_password_hash, 723 }, 724 }, 725 }, 726 .{ 727 .id = std.fmt.bytesToHex(repo_event_id, .lower), 728 .event = .{ 729 .repo = .{ 730 .user_id = &user_event_id, 731 .name = "ziglings", 732 .description = "Learn the Zig programming language by fixing tiny broken programs", 733 .enable_issue = true, 734 }, 735 }, 736 }, 737 }; 738 739 // commit and consume the seed events 740 try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume); 741 742 { 743 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 744 745 // get the map of users 746 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->user")) orelse return error.NotFound; 747 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); 748 749 // get the user out of the map that was edited 750 const user_cursor = try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 751 const user_map = try Repo.DB.HashMap(.read_only).init(user_cursor); 752 const user_event = try evt.read(evt.User, Repo.DB, repo_opts.hash, &arena, user_map); 753 754 // the password was correctly edited 755 try std.testing.expectEqualStrings(events_to_consume[1].event.user.?.password_hash, user_event.password_hash); 756 757 // get the map of repos 758 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 759 const event_id_to_repo = try Repo.DB.HashMap(.read_only).init(event_id_to_repo_cursor); 760 761 // get the repo out of the map 762 const repo_cursor = try event_id_to_repo.getCursor(hash.hashInt(repo_opts.hash, &repo_event_id)) orelse return error.NotFound; 763 const repo_map = try Repo.DB.HashMap(.read_only).init(repo_cursor); 764 const repo_event = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, repo_map); 765 766 try std.testing.expectEqualSlices(u8, &user_event_id, repo_event.user_id); 767 try std.testing.expectEqualStrings("ziglings", repo_event.name); 768 769 // get the repos created by the user 770 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; 771 const user_id_to_repo_id_set = try Repo.DB.HashMap(.read_only).init(user_id_to_repo_id_set_cursor); 772 773 const user_repos_cursor = try user_id_to_repo_id_set.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 774 const user_repos = try Repo.DB.SortedSet(.read_only).init(user_repos_cursor); 775 776 try std.testing.expectEqual(1, try user_repos.count()); 777 778 // the set is keyed by orderKey([created-ts][event-id]) 779 const order_key = evt.orderKey(repo_event.created_ts, &repo_event_id); 780 try std.testing.expect(try user_repos.contains(&order_key)); 781 } 782 783 // 784 // remove the repo 785 // 786 787 const events_to_consume2 = [_]evt.EventWithId{ 788 .{ 789 .id = std.fmt.bytesToHex(repo_event_id, .lower), 790 .event = .{ .repo = null }, 791 }, 792 }; 793 794 // commit and consume the removal 795 try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume2); 796 797 { 798 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 799 800 // get the map of repos 801 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 802 const event_id_to_repo = try Repo.DB.HashMap(.read_only).init(event_id_to_repo_cursor); 803 804 try std.testing.expect(null == try event_id_to_repo.getCursor(hash.hashInt(repo_opts.hash, &repo_event_id))); 805 806 // get the repos created by the user 807 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; 808 const user_id_to_repo_id_set = try Repo.DB.HashMap(.read_only).init(user_id_to_repo_id_set_cursor); 809 810 const user_repos_cursor = try user_id_to_repo_id_set.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 811 const user_repos = try Repo.DB.SortedSet(.read_only).init(user_repos_cursor); 812 813 // removing the repo emptied the user's set 814 try std.testing.expectEqual(0, try user_repos.count()); 815 } 816 817 // 818 // remove the user 819 // 820 821 const events_to_consume3 = [_]evt.EventWithId{ 822 .{ 823 .id = std.fmt.bytesToHex(user_event_id, .lower), 824 .event = .{ .user = null }, 825 }, 826 }; 827 828 // commit and consume the removal 829 try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume3); 830 831 { 832 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 833 834 // get the map of users 835 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->user")) orelse return error.NotFound; 836 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); 837 838 try std.testing.expect(null == try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id))); 839 } 840 } 841 842 test "repos and users paginate in creation order" { 843 const io = std.testing.io; 844 const allocator = std.testing.allocator; 845 const temp_dir_name = "temp-event-order"; 846 847 const cwd = std.Io.Dir.cwd(); 848 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 849 if (temp_dir_or_err) |*temp_dir| { 850 temp_dir.close(io); 851 try cwd.deleteTree(io, temp_dir_name); 852 } else |_| {} 853 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 854 defer cwd.deleteTree(io, temp_dir_name) catch {}; 855 defer temp_dir.close(io); 856 857 const cwd_path = try std.process.currentPathAlloc(io, allocator); 858 defer allocator.free(cwd_path); 859 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 860 defer allocator.free(work_path); 861 862 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 863 const Repo = rp.Repo(.xit, repo_opts); 864 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 865 defer repo.deinit(io, allocator); 866 867 var arena = std.heap.ArenaAllocator.init(allocator); 868 defer arena.deinit(); 869 870 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 871 const user_id = evt.EventWithId.randomId(prng.random()); 872 var repo_ids: [4][evt.event_id_size]u8 = undefined; 873 for (&repo_ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); 874 875 var pw_buf: [evt.User.password_hash_max_len]u8 = undefined; 876 const pw = try evt.User.hashPassword("pw", &pw_buf, io); 877 878 // asserts the repo-id-set holds exactly `expected` ids in that order 879 const Check = struct { 880 fn order( 881 comptime DB: type, 882 comptime hash_kind: hash.HashKind, 883 moment: DB.HashMap(.read_only), 884 expected: []const *const [evt.event_id_size]u8, 885 ) !void { 886 const cursor = try moment.getCursor(hash.hashInt(hash_kind, "repo-id-set")) orelse return error.NotFound; 887 const set = try DB.SortedSet(.read_only).init(cursor); 888 try std.testing.expectEqual(expected.len, try set.count()); 889 for (expected, 0..) |id, i| { 890 const kv = (try set.getIndexKeyValuePair(@intCast(i))) orelse return error.NotFound; 891 // the key is orderKey ([timestamp][event-id]); its trailing bytes are the id 892 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 893 _ = try kv.key_cursor.readBytes(&order_key); 894 try std.testing.expectEqualSlices(u8, id, order_key[@sizeOf(u64)..]); 895 } 896 } 897 }; 898 899 // one user, then four repos, each with its own creation timestamp (user@100, 900 // repos@101..104) 901 const events = [_]evt.EventWithId{ 902 .{ .id = std.fmt.bytesToHex(user_id, .lower), .timestamp = 100, .event = .{ .user = .{ .name = "alice", .display_name = "Alice", .email = "alice@example.test", .password_hash = pw } } }, 903 .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 101, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "d0", .enable_issue = true } } }, 904 .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 102, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo1", .description = "d1", .enable_issue = true } } }, 905 .{ .id = std.fmt.bytesToHex(repo_ids[2], .lower), .timestamp = 103, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo2", .description = "d2", .enable_issue = true } } }, 906 .{ .id = std.fmt.bytesToHex(repo_ids[3], .lower), .timestamp = 104, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo3", .description = "d3", .enable_issue = true } } }, 907 }; 908 try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &events); 909 910 { 911 const moment = try evt.currentMoment(repo_opts, &repo); 912 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[1], &repo_ids[2], &repo_ids[3] }); 913 914 // the single user shows up in its own ordered set 915 const ucur = try moment.getCursor(hash.hashInt(repo_opts.hash, "user-id-set")) orelse return error.NotFound; 916 const uset = try Repo.DB.SortedSet(.read_only).init(ucur); 917 try std.testing.expectEqual(1, try uset.count()); 918 } 919 920 // delete repo1 -> dense order (no tombstone) 921 try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{ 922 .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 200, .event = .{ .repo = null } }, 923 }); 924 { 925 const moment = try evt.currentMoment(repo_opts, &repo); 926 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[2], &repo_ids[3] }); 927 } 928 929 // update repo0 at a later timestamp -> keeps its original slot 930 try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{ 931 .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 300, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "updated", .enable_issue = true } } }, 932 }); 933 { 934 const moment = try evt.currentMoment(repo_opts, &repo); 935 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[2], &repo_ids[3] }); 936 937 // the value really was updated 938 const e2r_cur = try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 939 const e2r = try Repo.DB.HashMap(.read_only).init(e2r_cur); 940 const rc = try e2r.getCursor(hash.hashInt(repo_opts.hash, &repo_ids[0])) orelse return error.NotFound; 941 const rm = try Repo.DB.HashMap(.read_only).init(rc); 942 const re = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, rm); 943 try std.testing.expectEqualStrings("updated", re.description); 944 } 945 } 946