╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ event.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
8 const io = std.testing.io; 9 const allocator = std.testing.allocator; 10 const temp_dir_name = "temp-event-rebase"; 11 12 // create the temp dir 13 const cwd = std.Io.Dir.cwd(); 14 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 15 if (temp_dir_or_err) |*temp_dir| { 16 temp_dir.close(io); 17 try cwd.deleteTree(io, temp_dir_name); 18 } else |_| {} 19 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 20 defer cwd.deleteTree(io, temp_dir_name) catch {}; 21 defer temp_dir.close(io); 22 23 const cwd_path = try std.process.currentPathAlloc(io, allocator); 24 defer allocator.free(cwd_path); 25 26 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 27 defer allocator.free(work_path); 28 29 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 30 const Repo = rp.Repo(.xit, repo_opts); 31 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 32 defer repo.deinit(io, allocator); 33 34 var arena = std.heap.ArenaAllocator.init(allocator); 35 defer arena.deinit(); 36 37 // 38 // define test events 39 // 40 41 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 42 43 const first_event_id = evt.EventWithId.randomId(prng.random()); 44 45 const events_to_consume = [_]evt.EventWithId{ 46 .{ 47 .id = std.fmt.bytesToHex(first_event_id, .lower), 48 .event = .{ 49 .issue = .{ 50 .title = "Login form clears password on validation error", 51 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 52 .tags = "bug\x00priority-high\x00ui", 53 }, 54 }, 55 }, 56 // this event edits the previous one because it has the same id 57 .{ 58 .id = std.fmt.bytesToHex(first_event_id, .lower), 59 .event = .{ 60 .issue = .{ 61 .title = "Login form clears password on validation error", 62 .description = "Submitting an invalid email address resets the password field and removes typed input. Preserve the field value and show an inline validation message.", 63 .tags = "bug\x00priority-low\x00ui", 64 }, 65 }, 66 }, 67 .{ 68 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 69 .event = .{ 70 .issue = .{ 71 .title = "Search results ignore archived project filter", 72 .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.", 73 .tags = "bug\x00search\x00backend", 74 }, 75 }, 76 }, 77 .{ 78 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 79 .event = .{ 80 .issue = .{ 81 .title = "Issue list does not persist selected sort order", 82 .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.", 83 .tags = "enhancement\x00frontend\x00preferences", 84 }, 85 }, 86 }, 87 }; 88 89 // 90 // insert issues as commits in the repo 91 // 92 93 var first_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 94 95 { 96 var json: std.Io.Writer.Allocating = .init(allocator); 97 defer json.deinit(); 98 99 for (events_to_consume, 0..) |event, i| { 100 json.clearRetainingCapacity(); 101 102 try std.json.Stringify.value(event, .{}, &json.writer); 103 104 // commit the event into a special branch 105 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, .{ .kind = .head, .name = "haxy/meta" }); 106 if (i == 0) { 107 _ = try std.fmt.hexToBytes(&first_oid, &oid); 108 } 109 } 110 } 111 112 // 113 // consume events into the database 114 // 115 116 { 117 try evt.consume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }); 118 119 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 120 121 // get the map of issues 122 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 123 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 124 125 // get the issue out of the map that was edited 126 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id)) orelse return error.NotFound; 127 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 128 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 129 130 // the description was correctly edited 131 try std.testing.expectEqualStrings(events_to_consume[1].event.issue.?.description, first_issue.description); 132 133 // the tags were correctly edited 134 try std.testing.expectEqualStrings(events_to_consume[1].event.issue.?.tags, first_issue.tags); 135 } 136 137 // 138 // add another event 139 // 140 141 const events_to_consume2 = [_]evt.EventWithId{ 142 .{ 143 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 144 .event = .{ 145 .issue = .{ 146 .title = "Double clicking causes the form to submit twice", 147 .description = "When I double click the post button I see duplicate posts.", 148 .tags = "bug\x00priority-high\x00ui", 149 }, 150 }, 151 }, 152 }; 153 154 // commit and consume the new event 155 try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &events_to_consume2); 156 157 // 158 // rebase the branch so it no longer includes the edit event 159 // 160 161 const events_to_consume3 = [_]evt.EventWithId{ 162 events_to_consume[2], 163 events_to_consume[3], 164 }; 165 166 { 167 var json: std.Io.Writer.Allocating = .init(allocator); 168 defer json.deinit(); 169 170 for (events_to_consume3, 0..) |event, i| { 171 json.clearRetainingCapacity(); 172 173 try std.json.Stringify.value(event, .{}, &json.writer); 174 175 // commit the event into a special branch 176 _ = try repo.commitAtRef( 177 io, 178 allocator, 179 .{ 180 .message = json.written(), 181 // the first event should have the first oid as its parent, 182 // because we want to keep the very first commit. 183 // and the rest will have null which causes them 184 // to have the previous commit as their parent 185 .parent_oids = if (i == 0) &.{std.fmt.bytesToHex(first_oid, .lower)} else null, 186 }, 187 null, 188 .{ .kind = .head, .name = "haxy/meta" }, 189 ); 190 } 191 } 192 193 // 194 // consume events into the database 195 // 196 197 { 198 try evt.consume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }); 199 200 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 201 202 // get the map of issues 203 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 204 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 205 206 // get the issue out of the map that was edited 207 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id)) orelse return error.NotFound; 208 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 209 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 210 211 // the description is no longer edited 212 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.description); 213 214 // the tags are no longer edited 215 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.tags); 216 217 // an event added by the second push is no longer there because it was wiped out by the rebase 218 try std.testing.expect(null == try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &events_to_consume2[0].id))); 219 } 220 221 // 222 // rebase the branch so it no longer includes the original issue 223 // 224 225 { 226 var json: std.Io.Writer.Allocating = .init(allocator); 227 defer json.deinit(); 228 229 for (events_to_consume3, 0..) |event, i| { 230 json.clearRetainingCapacity(); 231 232 try std.json.Stringify.value(event, .{}, &json.writer); 233 234 // commit the event into a special branch 235 _ = try repo.commitAtRef( 236 io, 237 allocator, 238 .{ 239 .message = json.written(), 240 // the first event should have no parents, 241 // because we want to drop the very first commit. 242 // and the rest will have null which causes them 243 // to have the previous commit as their parent 244 .parent_oids = if (i == 0) &.{} else null, 245 }, 246 null, 247 .{ .kind = .head, .name = "haxy/meta" }, 248 ); 249 } 250 } 251 252 // 253 // consume events into the database 254 // 255 256 { 257 try evt.consume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }); 258 259 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 260 261 // get the map of issues 262 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 263 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 264 265 // the first issue no longer exists 266 try std.testing.expect(null == try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id))); 267 } 268 } 269 270 test "merge" { 271 const io = std.testing.io; 272 const allocator = std.testing.allocator; 273 const temp_dir_name = "temp-event-merge"; 274 275 // create the temp dir 276 const cwd = std.Io.Dir.cwd(); 277 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 278 if (temp_dir_or_err) |*temp_dir| { 279 temp_dir.close(io); 280 try cwd.deleteTree(io, temp_dir_name); 281 } else |_| {} 282 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 283 defer cwd.deleteTree(io, temp_dir_name) catch {}; 284 defer temp_dir.close(io); 285 286 const cwd_path = try std.process.currentPathAlloc(io, allocator); 287 defer allocator.free(cwd_path); 288 289 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 290 defer allocator.free(work_path); 291 292 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 293 const Repo = rp.Repo(.xit, repo_opts); 294 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 295 defer repo.deinit(io, allocator); 296 297 var arena = std.heap.ArenaAllocator.init(allocator); 298 defer arena.deinit(); 299 300 // 301 // define test events 302 // 303 304 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 305 306 const events_to_consume = [_]evt.EventWithId{ 307 .{ 308 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 309 .event = .{ 310 .issue = .{ 311 .title = "Login form clears password on validation error", 312 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 313 .tags = "bug\x00priority-high\x00ui", 314 }, 315 }, 316 }, 317 .{ 318 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 319 .event = .{ 320 .issue = .{ 321 .title = "Search results ignore archived project filter", 322 .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.", 323 .tags = "bug\x00search\x00backend", 324 }, 325 }, 326 }, 327 .{ 328 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 329 .event = .{ 330 .issue = .{ 331 .title = "Issue list does not persist selected sort order", 332 .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.", 333 .tags = "enhancement\x00frontend\x00preferences", 334 }, 335 }, 336 }, 337 }; 338 339 // 340 // insert issues as commits in the repo 341 // 342 343 var first_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 344 345 { 346 var json: std.Io.Writer.Allocating = .init(allocator); 347 defer json.deinit(); 348 349 for (events_to_consume, 0..) |event, i| { 350 json.clearRetainingCapacity(); 351 352 try std.json.Stringify.value(event, .{}, &json.writer); 353 354 // commit the event into a special branch 355 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, .{ .kind = .head, .name = "haxy/meta" }); 356 if (i == 0) { 357 _ = try std.fmt.hexToBytes(&first_oid, &oid); 358 } 359 } 360 } 361 362 // 363 // consume events into the database 364 // 365 366 { 367 try evt.consume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }); 368 369 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 370 371 // get the map of issues 372 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 373 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 374 375 // get the issue out of the map 376 var first_issue_id: [evt.event_id_size]u8 = undefined; 377 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume[0].id); 378 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 379 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 380 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 381 382 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.description); 383 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.tags); 384 } 385 386 // 387 // define more test events 388 // 389 390 const events_to_consume2 = [_]evt.EventWithId{ 391 .{ 392 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 393 .event = .{ 394 .issue = .{ 395 .title = "Kanban card status badge falls behind after drag", 396 .description = "Moving an issue between columns updates the card position immediately, but the status badge keeps showing the previous state until refresh.", 397 .tags = "bug\x00kanban\x00frontend", 398 }, 399 }, 400 }, 401 .{ 402 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 403 .event = .{ 404 .issue = .{ 405 .title = "Assignee autocomplete omits recently invited users", 406 .description = "Users invited during the current session do not appear in the assignee picker until the project page is reloaded.", 407 .tags = "bug\x00assignees\x00api", 408 }, 409 }, 410 }, 411 .{ 412 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 413 .event = .{ 414 .issue = .{ 415 .title = "Add due date warning for issues blocked by dependencies", 416 .description = "Show a warning when an issue's due date is earlier than an unresolved blocking issue so planners can adjust the schedule.", 417 .tags = "enhancement\x00planning\x00dependencies", 418 }, 419 }, 420 }, 421 }; 422 423 // 424 // insert issues as commits in the repo 425 // 426 427 { 428 var json: std.Io.Writer.Allocating = .init(allocator); 429 defer json.deinit(); 430 431 for (events_to_consume2, 0..) |event, i| { 432 json.clearRetainingCapacity(); 433 434 try std.json.Stringify.value(event, .{}, &json.writer); 435 436 // commit the event into a special branch 437 _ = try repo.commitAtRef( 438 io, 439 allocator, 440 // make the first commit a child of the first commit on the haxy/meta branch 441 .{ .parent_oids = if (i == 0) &.{std.fmt.bytesToHex(first_oid, .lower)} else null, .message = json.written() }, 442 null, 443 .{ .kind = .head, .name = "haxy/other" }, 444 ); 445 } 446 } 447 448 // 449 // check out the haxy/meta branch and merge the haxy/other branch into it 450 // 451 452 const merge_oid = blk: { 453 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "haxy/meta" } } }); 454 defer result.deinit(); 455 456 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = .{ .kind = .head, .name = "haxy/other" } }} } } }, null); 457 defer merge.deinit(); 458 459 try std.testing.expect(.success == merge.result); 460 461 break :blk merge.result.success.oid; 462 }; 463 464 // 465 // consume events into the database 466 // 467 468 { 469 try evt.consume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }); 470 471 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 472 473 // get the map of issues 474 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 475 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 476 477 // make sure one of the original issues is still there 478 { 479 // get the issue out of the map 480 var first_issue_id: [evt.event_id_size]u8 = undefined; 481 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume[0].id); 482 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 483 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 484 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 485 486 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.description); 487 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.tags); 488 } 489 490 // make sure one of the new issues is there 491 { 492 // get the issue out of the map 493 var first_issue_id: [evt.event_id_size]u8 = undefined; 494 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 495 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 496 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 497 const first_issue = try evt.read(evt.Issue, Repo.DB, repo_opts.hash, &arena, first_issue_map); 498 499 try std.testing.expectEqualStrings(events_to_consume2[0].event.issue.?.description, first_issue.description); 500 try std.testing.expectEqualStrings(events_to_consume2[0].event.issue.?.tags, first_issue.tags); 501 } 502 503 // the ordered issue set unions both branches' additions 504 { 505 const issue_id_set_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "issue-id-set")) orelse return error.NotFound; 506 const issue_id_set = try Repo.DB.SortedSet(.read_only).init(issue_id_set_cursor); 507 try std.testing.expectEqual(6, try issue_id_set.count()); 508 509 var found = [_]bool{false} ** 6; 510 var iter = try issue_id_set.iteratorFromIndex(0); 511 while (try iter.next()) |id_cursor_val| { 512 var id_cursor = id_cursor_val; 513 const id_kv = try id_cursor.readKeyValuePair(); 514 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 515 _ = try id_kv.key_cursor.readBytes(&order_key); 516 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 517 for (events_to_consume, 0..) |event, i| { 518 if (std.mem.eql(u8, &id_hex, &event.id)) found[i] = true; 519 } 520 for (events_to_consume2, 0..) |event, i| { 521 if (std.mem.eql(u8, &id_hex, &event.id)) found[events_to_consume.len + i] = true; 522 } 523 } 524 for (found) |f| try std.testing.expect(f); 525 } 526 } 527 528 // 529 // insert issues as commits in the repo 530 // 531 532 { 533 var json: std.Io.Writer.Allocating = .init(allocator); 534 defer json.deinit(); 535 536 for (events_to_consume, 0..) |event, i| { 537 json.clearRetainingCapacity(); 538 539 try std.json.Stringify.value(event, .{}, &json.writer); 540 541 // commit the event into a special branch 542 _ = try repo.commitAtRef( 543 io, 544 allocator, 545 // make the first event have no parents so we completely rebase the branch, 546 // undoing the merge that we did above 547 .{ .parent_oids = if (i == 0) &.{} else null, .message = json.written() }, 548 null, 549 .{ .kind = .head, .name = "haxy/meta" }, 550 ); 551 } 552 } 553 554 // 555 // consume events into the database 556 // 557 558 { 559 try evt.consume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }); 560 561 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 562 563 // get the map of issues 564 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 565 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 566 567 // make sure the new issue is not there 568 { 569 // get the issue out of the map 570 var first_issue_id: [evt.event_id_size]u8 = undefined; 571 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 572 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)); 573 try std.testing.expect(null == first_issue_cursor); 574 } 575 } 576 577 // 578 // define test events that modify the same issue in conflicting ways 579 // 580 581 const events_to_consume3 = [_]evt.EventWithId{ 582 .{ 583 .id = events_to_consume[0].id, 584 .event = .{ 585 .issue = .{ 586 .title = "Login form clears password on validation error", 587 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 588 .tags = "bug\x00priority-low\x00ui", 589 }, 590 }, 591 }, 592 .{ 593 .id = events_to_consume[0].id, 594 .event = .{ 595 .issue = .{ 596 .title = "Login form clears password on validation error", 597 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 598 .tags = "bug\x00priority-medium\x00ui", 599 }, 600 }, 601 }, 602 }; 603 604 // 605 // insert issues as commits in the repo on different branches 606 // 607 608 { 609 var json: std.Io.Writer.Allocating = .init(allocator); 610 defer json.deinit(); 611 612 for (events_to_consume3, 0..) |event, i| { 613 json.clearRetainingCapacity(); 614 615 try std.json.Stringify.value(event, .{}, &json.writer); 616 617 // commit the event into a special branch 618 _ = try repo.commitAtRef( 619 io, 620 allocator, 621 .{ .parent_oids = &.{merge_oid}, .message = json.written() }, 622 null, 623 .{ .kind = .head, .name = switch (i) { 624 0 => "haxy/meta", 625 1 => "haxy/other", 626 else => unreachable, 627 } }, 628 ); 629 } 630 } 631 632 // 633 // check out the haxy/meta branch and merge the haxy/other branch into it 634 // 635 636 { 637 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "haxy/meta" } } }); 638 defer result.deinit(); 639 640 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = .{ .kind = .head, .name = "haxy/other" } }} } } }, null); 641 defer merge.deinit(); 642 643 try std.testing.expect(.success == merge.result); 644 } 645 646 // 647 // consume events into the database 648 // 649 650 try std.testing.expectError(error.MergeConflict, evt.consume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" })); 651 } 652 653 test "user and repo" { 654 const io = std.testing.io; 655 const allocator = std.testing.allocator; 656 const temp_dir_name = "temp-event-user-and-repo"; 657 658 // create the temp dir 659 const cwd = std.Io.Dir.cwd(); 660 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 661 if (temp_dir_or_err) |*temp_dir| { 662 temp_dir.close(io); 663 try cwd.deleteTree(io, temp_dir_name); 664 } else |_| {} 665 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 666 defer cwd.deleteTree(io, temp_dir_name) catch {}; 667 defer temp_dir.close(io); 668 669 const cwd_path = try std.process.currentPathAlloc(io, allocator); 670 defer allocator.free(cwd_path); 671 672 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 673 defer allocator.free(work_path); 674 675 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 676 const Repo = rp.Repo(.xit, repo_opts); 677 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 678 defer repo.deinit(io, allocator); 679 680 var arena = std.heap.ArenaAllocator.init(allocator); 681 defer arena.deinit(); 682 683 // 684 // define test events 685 // 686 687 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 688 const user_event_id = evt.EventWithId.randomId(prng.random()); 689 const repo_event_id = evt.EventWithId.randomId(prng.random()); 690 691 var first_password_hash_buf: [evt.User.password_hash_max_len]u8 = undefined; 692 const first_password_hash = try evt.User.hashPassword("correct horse battery staple", &first_password_hash_buf, io); 693 694 var second_password_hash_buf: [evt.User.password_hash_max_len]u8 = undefined; 695 const second_password_hash = try evt.User.hashPassword("Tr0ub4dor&3", &second_password_hash_buf, io); 696 697 const events_to_consume = [_]evt.EventWithId{ 698 .{ 699 .id = std.fmt.bytesToHex(user_event_id, .lower), 700 .event = .{ 701 .user = .{ 702 .name = "alice", 703 .display_name = "Alice Example", 704 .email = "alice@example.test", 705 .password_hash = first_password_hash, 706 }, 707 }, 708 }, 709 // this event edits the previous one because it has the same id 710 .{ 711 .id = std.fmt.bytesToHex(user_event_id, .lower), 712 .event = .{ 713 .user = .{ 714 .name = "alice", 715 .display_name = "Alice Example", 716 .email = "alice@example.test", 717 .password_hash = second_password_hash, 718 }, 719 }, 720 }, 721 .{ 722 .id = std.fmt.bytesToHex(repo_event_id, .lower), 723 .event = .{ 724 .repo = .{ 725 .user_id = &user_event_id, 726 .name = "ziglings", 727 .description = "Learn the Zig programming language by fixing tiny broken programs", 728 .enable_issue = true, 729 }, 730 }, 731 }, 732 }; 733 734 // commit and consume the seed events 735 try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &events_to_consume); 736 737 { 738 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 739 740 // get the map of users 741 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->user")) orelse return error.NotFound; 742 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); 743 744 // get the user out of the map that was edited 745 const user_cursor = try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 746 const user_map = try Repo.DB.HashMap(.read_only).init(user_cursor); 747 const user_event = try evt.read(evt.User, Repo.DB, repo_opts.hash, &arena, user_map); 748 749 // the password was correctly edited 750 try std.testing.expectEqualStrings(events_to_consume[1].event.user.?.password_hash, user_event.password_hash); 751 752 // get the map of repos 753 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 754 const event_id_to_repo = try Repo.DB.HashMap(.read_only).init(event_id_to_repo_cursor); 755 756 // get the repo out of the map 757 const repo_cursor = try event_id_to_repo.getCursor(hash.hashInt(repo_opts.hash, &repo_event_id)) orelse return error.NotFound; 758 const repo_map = try Repo.DB.HashMap(.read_only).init(repo_cursor); 759 const repo_event = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, repo_map); 760 761 try std.testing.expectEqualSlices(u8, &user_event_id, repo_event.user_id); 762 try std.testing.expectEqualStrings("ziglings", repo_event.name); 763 764 // get the repos created by the user 765 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; 766 const user_id_to_repo_id_set = try Repo.DB.HashMap(.read_only).init(user_id_to_repo_id_set_cursor); 767 768 const user_repos_cursor = try user_id_to_repo_id_set.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 769 const user_repos = try Repo.DB.SortedSet(.read_only).init(user_repos_cursor); 770 771 try std.testing.expectEqual(1, try user_repos.count()); 772 773 // the set is keyed by orderKey([created-ts][event-id]) 774 const order_key = evt.orderKey(repo_event.created_ts, &repo_event_id); 775 try std.testing.expect(try user_repos.contains(&order_key)); 776 } 777 778 // 779 // remove the repo 780 // 781 782 const events_to_consume2 = [_]evt.EventWithId{ 783 .{ 784 .id = std.fmt.bytesToHex(repo_event_id, .lower), 785 .event = .{ .repo = null }, 786 }, 787 }; 788 789 // commit and consume the removal 790 try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &events_to_consume2); 791 792 { 793 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 794 795 // get the map of repos 796 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 797 const event_id_to_repo = try Repo.DB.HashMap(.read_only).init(event_id_to_repo_cursor); 798 799 try std.testing.expect(null == try event_id_to_repo.getCursor(hash.hashInt(repo_opts.hash, &repo_event_id))); 800 801 // get the repos created by the user 802 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; 803 const user_id_to_repo_id_set = try Repo.DB.HashMap(.read_only).init(user_id_to_repo_id_set_cursor); 804 805 const user_repos_cursor = try user_id_to_repo_id_set.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 806 const user_repos = try Repo.DB.SortedSet(.read_only).init(user_repos_cursor); 807 808 // removing the repo emptied the user's set 809 try std.testing.expectEqual(0, try user_repos.count()); 810 } 811 812 // 813 // remove the user 814 // 815 816 const events_to_consume3 = [_]evt.EventWithId{ 817 .{ 818 .id = std.fmt.bytesToHex(user_event_id, .lower), 819 .event = .{ .user = null }, 820 }, 821 }; 822 823 // commit and consume the removal 824 try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &events_to_consume3); 825 826 { 827 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 828 829 // get the map of users 830 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->user")) orelse return error.NotFound; 831 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); 832 833 try std.testing.expect(null == try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id))); 834 } 835 } 836 837 test "repos and users paginate in creation order" { 838 const io = std.testing.io; 839 const allocator = std.testing.allocator; 840 const temp_dir_name = "temp-event-order"; 841 842 const cwd = std.Io.Dir.cwd(); 843 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 844 if (temp_dir_or_err) |*temp_dir| { 845 temp_dir.close(io); 846 try cwd.deleteTree(io, temp_dir_name); 847 } else |_| {} 848 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 849 defer cwd.deleteTree(io, temp_dir_name) catch {}; 850 defer temp_dir.close(io); 851 852 const cwd_path = try std.process.currentPathAlloc(io, allocator); 853 defer allocator.free(cwd_path); 854 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 855 defer allocator.free(work_path); 856 857 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 858 const Repo = rp.Repo(.xit, repo_opts); 859 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 860 defer repo.deinit(io, allocator); 861 862 var arena = std.heap.ArenaAllocator.init(allocator); 863 defer arena.deinit(); 864 865 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 866 const user_id = evt.EventWithId.randomId(prng.random()); 867 var repo_ids: [4][evt.event_id_size]u8 = undefined; 868 for (&repo_ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); 869 870 var pw_buf: [evt.User.password_hash_max_len]u8 = undefined; 871 const pw = try evt.User.hashPassword("pw", &pw_buf, io); 872 873 // asserts the repo-id-set holds exactly `expected` ids in that order 874 const Check = struct { 875 fn order( 876 comptime DB: type, 877 comptime hash_kind: hash.HashKind, 878 moment: DB.HashMap(.read_only), 879 expected: []const *const [evt.event_id_size]u8, 880 ) !void { 881 const cursor = try moment.getCursor(hash.hashInt(hash_kind, "repo-id-set")) orelse return error.NotFound; 882 const set = try DB.SortedSet(.read_only).init(cursor); 883 try std.testing.expectEqual(expected.len, try set.count()); 884 for (expected, 0..) |id, i| { 885 const kv = (try set.getIndexKeyValuePair(@intCast(i))) orelse return error.NotFound; 886 // the key is orderKey ([timestamp][event-id]); its trailing bytes are the id 887 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 888 _ = try kv.key_cursor.readBytes(&order_key); 889 try std.testing.expectEqualSlices(u8, id, order_key[@sizeOf(u64)..]); 890 } 891 } 892 }; 893 894 // one user, then four repos, each with its own creation timestamp (user@100, 895 // repos@101..104) 896 const events = [_]evt.EventWithId{ 897 .{ .id = std.fmt.bytesToHex(user_id, .lower), .timestamp = 100, .event = .{ .user = .{ .name = "alice", .display_name = "Alice", .email = "alice@example.test", .password_hash = pw } } }, 898 .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 101, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "d0", .enable_issue = true } } }, 899 .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 102, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo1", .description = "d1", .enable_issue = true } } }, 900 .{ .id = std.fmt.bytesToHex(repo_ids[2], .lower), .timestamp = 103, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo2", .description = "d2", .enable_issue = true } } }, 901 .{ .id = std.fmt.bytesToHex(repo_ids[3], .lower), .timestamp = 104, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo3", .description = "d3", .enable_issue = true } } }, 902 }; 903 try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &events); 904 905 { 906 const moment = try evt.currentMoment(repo_opts, &repo); 907 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[1], &repo_ids[2], &repo_ids[3] }); 908 909 // the single user shows up in its own ordered set 910 const ucur = try moment.getCursor(hash.hashInt(repo_opts.hash, "user-id-set")) orelse return error.NotFound; 911 const uset = try Repo.DB.SortedSet(.read_only).init(ucur); 912 try std.testing.expectEqual(1, try uset.count()); 913 } 914 915 // delete repo1 -> dense order (no tombstone) 916 try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &[_]evt.EventWithId{ 917 .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 200, .event = .{ .repo = null } }, 918 }); 919 { 920 const moment = try evt.currentMoment(repo_opts, &repo); 921 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[2], &repo_ids[3] }); 922 } 923 924 // update repo0 at a later timestamp -> keeps its original slot 925 try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &[_]evt.EventWithId{ 926 .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 300, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "updated", .enable_issue = true } } }, 927 }); 928 { 929 const moment = try evt.currentMoment(repo_opts, &repo); 930 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[2], &repo_ids[3] }); 931 932 // the value really was updated 933 const e2r_cur = try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 934 const e2r = try Repo.DB.HashMap(.read_only).init(e2r_cur); 935 const rc = try e2r.getCursor(hash.hashInt(repo_opts.hash, &repo_ids[0])) orelse return error.NotFound; 936 const rm = try Repo.DB.HashMap(.read_only).init(rc); 937 const re = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, rm); 938 try std.testing.expectEqualStrings("updated", re.description); 939 } 940 } 941