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