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