╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ event.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
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(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(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(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(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(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(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 issue_id_set_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "issue-id-set")) orelse return error.NotFound; 509 const issue_id_set = try Repo.DB.SortedSet(.read_only).init(issue_id_set_cursor); 510 try std.testing.expectEqual(6, try issue_id_set.count()); 511 512 var found = [_]bool{false} ** 6; 513 var iter = try issue_id_set.iteratorFromIndex(0); 514 while (try iter.next()) |id_cursor_val| { 515 var id_cursor = id_cursor_val; 516 const id_kv = try id_cursor.readKeyValuePair(); 517 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 518 _ = try id_kv.key_cursor.readBytes(&order_key); 519 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 520 for (events_to_consume, 0..) |event, i| { 521 if (std.mem.eql(u8, &id_hex, &event.id)) found[i] = true; 522 } 523 for (events_to_consume2, 0..) |event, i| { 524 if (std.mem.eql(u8, &id_hex, &event.id)) found[events_to_consume.len + i] = true; 525 } 526 } 527 for (found) |f| try std.testing.expect(f); 528 } 529 } 530 531 // 532 // insert issues as commits in the repo 533 // 534 535 { 536 var json: std.Io.Writer.Allocating = .init(allocator); 537 defer json.deinit(); 538 539 for (events_to_consume, 0..) |event, i| { 540 json.clearRetainingCapacity(); 541 542 try std.json.Stringify.value(event, .{}, &json.writer); 543 544 // commit the event into a special branch 545 _ = try repo.commitAtRef( 546 io, 547 allocator, 548 // make the first event have no parents so we completely rebase the branch, 549 // undoing the merge that we did above 550 .{ .parent_oids = if (i == 0) &.{} else null, .message = json.written() }, 551 null, 552 evt.events_ref, 553 ); 554 } 555 } 556 557 // 558 // consume events into the database 559 // 560 561 { 562 try evt.consume(repo_opts, io, allocator, &repo, evt.events_ref); 563 564 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 565 566 // get the map of issues 567 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 568 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 569 570 // make sure the new issue is not there 571 { 572 // get the issue out of the map 573 var first_issue_id: [evt.event_id_size]u8 = undefined; 574 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 575 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)); 576 try std.testing.expect(null == first_issue_cursor); 577 } 578 } 579 580 // 581 // define test events that modify the same issue in conflicting ways 582 // 583 584 const events_to_consume3 = [_]evt.EventWithId{ 585 .{ 586 .id = events_to_consume[0].id, 587 .event = .{ 588 .issue = .{ 589 .title = "Login form clears password on validation error", 590 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 591 .tags = "bug,priority-low,ui", 592 }, 593 }, 594 }, 595 .{ 596 .id = events_to_consume[0].id, 597 .event = .{ 598 .issue = .{ 599 .title = "Login form clears password on validation error", 600 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 601 .tags = "bug,priority-medium,ui", 602 }, 603 }, 604 }, 605 }; 606 607 // 608 // insert issues as commits in the repo on different branches 609 // 610 611 { 612 var json: std.Io.Writer.Allocating = .init(allocator); 613 defer json.deinit(); 614 615 for (events_to_consume3, 0..) |event, i| { 616 json.clearRetainingCapacity(); 617 618 try std.json.Stringify.value(event, .{}, &json.writer); 619 620 // commit the event into a special branch 621 _ = try repo.commitAtRef( 622 io, 623 allocator, 624 .{ .parent_oids = &.{merge_oid}, .message = json.written() }, 625 null, 626 switch (i) { 627 0 => evt.events_ref, 628 1 => other_events_ref, 629 else => unreachable, 630 }, 631 ); 632 } 633 } 634 635 // 636 // check out the events branch and merge the other events branch into it 637 // 638 639 { 640 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 641 defer result.deinit(); 642 643 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 644 defer merge.deinit(); 645 646 try std.testing.expect(.success == merge.result); 647 } 648 649 // 650 // consume events into the database 651 // 652 653 try std.testing.expectError(error.MergeConflict, evt.consume(repo_opts, io, allocator, &repo, evt.events_ref)); 654 } 655 656 test "user and repo" { 657 const io = std.testing.io; 658 const allocator = std.testing.allocator; 659 const temp_dir_name = "temp-event-user-and-repo"; 660 661 // create the temp dir 662 const cwd = std.Io.Dir.cwd(); 663 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 664 if (temp_dir_or_err) |*temp_dir| { 665 temp_dir.close(io); 666 try cwd.deleteTree(io, temp_dir_name); 667 } else |_| {} 668 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 669 defer cwd.deleteTree(io, temp_dir_name) catch {}; 670 defer temp_dir.close(io); 671 672 const cwd_path = try std.process.currentPathAlloc(io, allocator); 673 defer allocator.free(cwd_path); 674 675 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 676 defer allocator.free(work_path); 677 678 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 679 const Repo = rp.Repo(.xit, repo_opts); 680 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 681 defer repo.deinit(io, allocator); 682 683 var arena = std.heap.ArenaAllocator.init(allocator); 684 defer arena.deinit(); 685 686 // 687 // define test events 688 // 689 690 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 691 const user_event_id = evt.EventWithId.randomId(prng.random()); 692 const repo_event_id = evt.EventWithId.randomId(prng.random()); 693 694 var first_password_hash_buf: [evt.User.password_hash_max_len]u8 = undefined; 695 const first_password_hash = try evt.User.hashPassword("correct horse battery staple", &first_password_hash_buf, io); 696 697 var second_password_hash_buf: [evt.User.password_hash_max_len]u8 = undefined; 698 const second_password_hash = try evt.User.hashPassword("Tr0ub4dor&3", &second_password_hash_buf, io); 699 700 const events_to_consume = [_]evt.EventWithId{ 701 .{ 702 .id = std.fmt.bytesToHex(user_event_id, .lower), 703 .event = .{ 704 .user = .{ 705 .name = "alice", 706 .display_name = "Alice Example", 707 .email = "alice@example.test", 708 .password_hash = first_password_hash, 709 }, 710 }, 711 }, 712 // this event edits the previous one because it has the same id 713 .{ 714 .id = std.fmt.bytesToHex(user_event_id, .lower), 715 .event = .{ 716 .user = .{ 717 .name = "alice", 718 .display_name = "Alice Example", 719 .email = "alice@example.test", 720 .password_hash = second_password_hash, 721 }, 722 }, 723 }, 724 .{ 725 .id = std.fmt.bytesToHex(repo_event_id, .lower), 726 .event = .{ 727 .repo = .{ 728 .user_id = &user_event_id, 729 .name = "ziglings", 730 .description = "Learn the Zig programming language by fixing tiny broken programs", 731 .enable_issue = true, 732 }, 733 }, 734 }, 735 }; 736 737 // commit and consume the seed events 738 try evt.commitAndConsume(repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume); 739 740 { 741 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 742 743 // get the map of users 744 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->user")) orelse return error.NotFound; 745 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); 746 747 // get the user out of the map that was edited 748 const user_cursor = try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 749 const user_map = try Repo.DB.HashMap(.read_only).init(user_cursor); 750 const user_event = try evt.read(evt.User, Repo.DB, repo_opts.hash, &arena, user_map); 751 752 // the password was correctly edited 753 try std.testing.expectEqualStrings(events_to_consume[1].event.user.?.password_hash, user_event.password_hash); 754 755 // get the map of repos 756 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 757 const event_id_to_repo = try Repo.DB.HashMap(.read_only).init(event_id_to_repo_cursor); 758 759 // get the repo out of the map 760 const repo_cursor = try event_id_to_repo.getCursor(hash.hashInt(repo_opts.hash, &repo_event_id)) orelse return error.NotFound; 761 const repo_map = try Repo.DB.HashMap(.read_only).init(repo_cursor); 762 const repo_event = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, repo_map); 763 764 try std.testing.expectEqualSlices(u8, &user_event_id, repo_event.user_id); 765 try std.testing.expectEqualStrings("ziglings", repo_event.name); 766 767 // get the repos created by the user 768 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; 769 const user_id_to_repo_id_set = try Repo.DB.HashMap(.read_only).init(user_id_to_repo_id_set_cursor); 770 771 const user_repos_cursor = try user_id_to_repo_id_set.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 772 const user_repos = try Repo.DB.SortedSet(.read_only).init(user_repos_cursor); 773 774 try std.testing.expectEqual(1, try user_repos.count()); 775 776 // the set is keyed by orderKey([created-ts][event-id]) 777 const order_key = evt.orderKey(repo_event.created_ts, &repo_event_id); 778 try std.testing.expect(try user_repos.contains(&order_key)); 779 } 780 781 // 782 // remove the repo 783 // 784 785 const events_to_consume2 = [_]evt.EventWithId{ 786 .{ 787 .id = std.fmt.bytesToHex(repo_event_id, .lower), 788 .event = .{ .repo = null }, 789 }, 790 }; 791 792 // commit and consume the removal 793 try evt.commitAndConsume(repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume2); 794 795 { 796 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 797 798 // get the map of repos 799 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 800 const event_id_to_repo = try Repo.DB.HashMap(.read_only).init(event_id_to_repo_cursor); 801 802 try std.testing.expect(null == try event_id_to_repo.getCursor(hash.hashInt(repo_opts.hash, &repo_event_id))); 803 804 // get the repos created by the user 805 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; 806 const user_id_to_repo_id_set = try Repo.DB.HashMap(.read_only).init(user_id_to_repo_id_set_cursor); 807 808 const user_repos_cursor = try user_id_to_repo_id_set.getCursor(hash.hashInt(repo_opts.hash, &user_event_id)) orelse return error.NotFound; 809 const user_repos = try Repo.DB.SortedSet(.read_only).init(user_repos_cursor); 810 811 // removing the repo emptied the user's set 812 try std.testing.expectEqual(0, try user_repos.count()); 813 } 814 815 // 816 // remove the user 817 // 818 819 const events_to_consume3 = [_]evt.EventWithId{ 820 .{ 821 .id = std.fmt.bytesToHex(user_event_id, .lower), 822 .event = .{ .user = null }, 823 }, 824 }; 825 826 // commit and consume the removal 827 try evt.commitAndConsume(repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume3); 828 829 { 830 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 831 832 // get the map of users 833 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->user")) orelse return error.NotFound; 834 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); 835 836 try std.testing.expect(null == try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id))); 837 } 838 } 839 840 test "repos and users paginate in creation order" { 841 const io = std.testing.io; 842 const allocator = std.testing.allocator; 843 const temp_dir_name = "temp-event-order"; 844 845 const cwd = std.Io.Dir.cwd(); 846 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 847 if (temp_dir_or_err) |*temp_dir| { 848 temp_dir.close(io); 849 try cwd.deleteTree(io, temp_dir_name); 850 } else |_| {} 851 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 852 defer cwd.deleteTree(io, temp_dir_name) catch {}; 853 defer temp_dir.close(io); 854 855 const cwd_path = try std.process.currentPathAlloc(io, allocator); 856 defer allocator.free(cwd_path); 857 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 858 defer allocator.free(work_path); 859 860 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 861 const Repo = rp.Repo(.xit, repo_opts); 862 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 863 defer repo.deinit(io, allocator); 864 865 var arena = std.heap.ArenaAllocator.init(allocator); 866 defer arena.deinit(); 867 868 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 869 const user_id = evt.EventWithId.randomId(prng.random()); 870 var repo_ids: [4][evt.event_id_size]u8 = undefined; 871 for (&repo_ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); 872 873 var pw_buf: [evt.User.password_hash_max_len]u8 = undefined; 874 const pw = try evt.User.hashPassword("pw", &pw_buf, io); 875 876 // asserts the repo-id-set holds exactly `expected` ids in that order 877 const Check = struct { 878 fn order( 879 comptime DB: type, 880 comptime hash_kind: hash.HashKind, 881 moment: DB.HashMap(.read_only), 882 expected: []const *const [evt.event_id_size]u8, 883 ) !void { 884 const cursor = try moment.getCursor(hash.hashInt(hash_kind, "repo-id-set")) orelse return error.NotFound; 885 const set = try DB.SortedSet(.read_only).init(cursor); 886 try std.testing.expectEqual(expected.len, try set.count()); 887 for (expected, 0..) |id, i| { 888 const kv = (try set.getIndexKeyValuePair(@intCast(i))) orelse return error.NotFound; 889 // the key is orderKey ([timestamp][event-id]); its trailing bytes are the id 890 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 891 _ = try kv.key_cursor.readBytes(&order_key); 892 try std.testing.expectEqualSlices(u8, id, order_key[@sizeOf(u64)..]); 893 } 894 } 895 }; 896 897 // one user, then four repos, each with its own creation timestamp (user@100, 898 // repos@101..104) 899 const events = [_]evt.EventWithId{ 900 .{ .id = std.fmt.bytesToHex(user_id, .lower), .timestamp = 100, .event = .{ .user = .{ .name = "alice", .display_name = "Alice", .email = "alice@example.test", .password_hash = pw } } }, 901 .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 101, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "d0", .enable_issue = true } } }, 902 .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 102, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo1", .description = "d1", .enable_issue = true } } }, 903 .{ .id = std.fmt.bytesToHex(repo_ids[2], .lower), .timestamp = 103, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo2", .description = "d2", .enable_issue = true } } }, 904 .{ .id = std.fmt.bytesToHex(repo_ids[3], .lower), .timestamp = 104, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo3", .description = "d3", .enable_issue = true } } }, 905 }; 906 try evt.commitAndConsume(repo_opts, io, allocator, &repo, evt.events_ref, &events); 907 908 { 909 const moment = try evt.currentMoment(repo_opts, &repo); 910 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[1], &repo_ids[2], &repo_ids[3] }); 911 912 // the single user shows up in its own ordered set 913 const ucur = try moment.getCursor(hash.hashInt(repo_opts.hash, "user-id-set")) orelse return error.NotFound; 914 const uset = try Repo.DB.SortedSet(.read_only).init(ucur); 915 try std.testing.expectEqual(1, try uset.count()); 916 } 917 918 // delete repo1 -> dense order (no tombstone) 919 try evt.commitAndConsume(repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{ 920 .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 200, .event = .{ .repo = null } }, 921 }); 922 { 923 const moment = try evt.currentMoment(repo_opts, &repo); 924 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[2], &repo_ids[3] }); 925 } 926 927 // update repo0 at a later timestamp -> keeps its original slot 928 try evt.commitAndConsume(repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{ 929 .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 300, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "updated", .enable_issue = true } } }, 930 }); 931 { 932 const moment = try evt.currentMoment(repo_opts, &repo); 933 try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[2], &repo_ids[3] }); 934 935 // the value really was updated 936 const e2r_cur = try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; 937 const e2r = try Repo.DB.HashMap(.read_only).init(e2r_cur); 938 const rc = try e2r.getCursor(hash.hashInt(repo_opts.hash, &repo_ids[0])) orelse return error.NotFound; 939 const rm = try Repo.DB.HashMap(.read_only).init(rc); 940 const re = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, rm); 941 try std.testing.expectEqualStrings("updated", re.description); 942 } 943 } 944