╭ event.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const hx = @import("haxy"); 3 const evt = hx.event; 4 const xit = hx.xit; 5 const rp = xit.repo; 6 const hash = xit.hash; 7 8 test "simple" { 9 const io = std.testing.io; 10 const allocator = std.testing.allocator; 11 const temp_dir_name = "temp-event-simple"; 12 13 // create the temp dir 14 const cwd = std.Io.Dir.cwd(); 15 var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); 16 if (temp_dir_or_err) |*temp_dir| { 17 temp_dir.close(io); 18 try cwd.deleteTree(io, temp_dir_name); 19 } else |_| {} 20 var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); 21 defer cwd.deleteTree(io, temp_dir_name) catch {}; 22 defer temp_dir.close(io); 23 24 const cwd_path = try std.process.currentPathAlloc(io, allocator); 25 defer allocator.free(cwd_path); 26 27 const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); 28 defer allocator.free(work_path); 29 30 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 31 const Repo = rp.Repo(.xit, repo_opts); 32 var repo = try Repo.init(io, allocator, .{ .path = work_path }); 33 defer repo.deinit(io, allocator); 34 35 // 36 // define test events 37 // 38 39 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 40 41 const first_event_id = evt.randomId(prng.random()); 42 43 const events_to_process = [_]evt.Event{ 44 .{ 45 .id = std.fmt.bytesToHex(first_event_id, .lower), 46 .data = .{ 47 .issue = .{ 48 .title = "Login form clears password on validation error", 49 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 50 .tags = &[_][]const u8{ "bug", "priority-high", "ui" }, 51 }, 52 }, 53 }, 54 // this event edits the previous one because it has the same id 55 .{ 56 .id = std.fmt.bytesToHex(first_event_id, .lower), 57 .data = .{ 58 .issue = .{ 59 .title = "Login form clears password on validation error", 60 .description = "Submitting an invalid email address resets the password field and removes typed input. Preserve the field value and show an inline validation message.", 61 .tags = &[_][]const u8{ "bug", "priority-high", "ui" }, 62 }, 63 }, 64 }, 65 .{ 66 .id = std.fmt.bytesToHex(evt.randomId(prng.random()), .lower), 67 .data = .{ 68 .issue = .{ 69 .title = "Search results ignore archived project filter", 70 .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.", 71 .tags = &[_][]const u8{ "bug", "search", "backend" }, 72 }, 73 }, 74 }, 75 .{ 76 .id = std.fmt.bytesToHex(evt.randomId(prng.random()), .lower), 77 .data = .{ 78 .issue = .{ 79 .title = "Issue list does not persist selected sort order", 80 .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.", 81 .tags = &[_][]const u8{ "enhancement", "frontend", "preferences" }, 82 }, 83 }, 84 }, 85 }; 86 87 // 88 // insert issues as commits in the repo 89 // 90 91 var arena = std.heap.ArenaAllocator.init(allocator); 92 defer arena.deinit(); 93 94 { 95 var json: std.Io.Writer.Allocating = .init(std.testing.allocator); 96 defer json.deinit(); 97 98 for (events_to_process) |event| { 99 json.clearRetainingCapacity(); 100 101 try std.json.Stringify.value(event, .{}, &json.writer); 102 103 // commit the event into a special branch 104 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, .{ .kind = .head, .name = "haxy/meta" }); 105 } 106 } 107 108 // 109 // read and parse all of the events from the repo 110 // 111 112 var events: std.ArrayList(evt.Event) = .empty; 113 defer events.deinit(allocator); 114 115 { 116 var event_strs: std.ArrayList([]const u8) = .empty; 117 defer event_strs.deinit(allocator); 118 119 const ref_haxy_meta = try repo.readRef(io, .{ .kind = .head, .name = "haxy/meta" }) orelse return error.RefNotFound; 120 var commit_iter = try repo.log(io, allocator, &.{ref_haxy_meta}); 121 defer commit_iter.deinit(); 122 123 // read the message from each commit 124 while (try commit_iter.next()) |commit_object| { 125 defer commit_object.deinit(); 126 127 try commit_object.object_reader.seekTo(commit_object.content.commit.message_position); 128 const message = try commit_object.object_reader.interface.allocRemaining(arena.allocator(), .unlimited); 129 130 try event_strs.append(allocator, message); 131 } 132 133 // parse commit messages as JSON into event values. 134 // add events in reverse order so the earliest event is first. 135 for (0..event_strs.items.len) |i| { 136 const event_str = event_strs.items[event_strs.items.len - i - 1]; 137 const event = try std.json.parseFromSliceLeaky(evt.Event, arena.allocator(), event_str, .{}); 138 try events.append(allocator, event); 139 } 140 } 141 142 // 143 // process events into the database 144 // 145 146 const Ctx = struct { 147 events: []evt.Event, 148 149 pub fn run(ctx: @This(), cursor: *Repo.DB.Cursor(.read_write)) !void { 150 const moment = try Repo.DB.HashMap(.read_write).init(cursor.*); 151 152 // the map with all of haxy's state and materialized views 153 const haxy_cursor = try moment.putCursor(hash.hashInt(repo_opts.hash, "haxy")); 154 const haxy = try Repo.DB.HashMap(.read_write).init(haxy_cursor); 155 156 // try reading the last event id that was processed in the db 157 var last_event_id_maybe: ?[evt.id_size]u8 = null; 158 if (try haxy.getCursor(hash.hashInt(repo_opts.hash, "last-event-id"))) |last_event_id_cursor| { 159 var last_event_id_buffer: [evt.id_size]u8 = undefined; 160 _ = try last_event_id_cursor.readBytes(&last_event_id_buffer); 161 last_event_id_maybe = last_event_id_buffer; 162 } 163 164 // for each event we want to process... 165 for (ctx.events) |event| { 166 // get the id of the current event as bytes 167 var current_event_id_buffer: [evt.id_size]u8 = undefined; 168 _ = try std.fmt.hexToBytes(&current_event_id_buffer, &event.id); 169 170 // map with the views as they appeared when each event was processed. 171 // we can use this to see (and revert) the views to any previous state. 172 const event_id_to_views_cursor = try haxy.putCursor(hash.hashInt(repo_opts.hash, "event-id->views")); 173 const event_id_to_views = try Repo.DB.HashMap(.read_write).init(event_id_to_views_cursor); 174 175 // create a new views map for the current event we are processing 176 var views_cursor = try event_id_to_views.putCursor(hash.hashInt(repo_opts.hash, &current_event_id_buffer)); 177 178 // if there was a previous event, set the views map to have the same value as it. 179 // this efficiently "clones" the map so we make further modifications based on it. 180 if (last_event_id_maybe) |*last_event_id| { 181 if (try event_id_to_views.getCursor(hash.hashInt(repo_opts.hash, last_event_id))) |last_view_cursor| { 182 try views_cursor.write(.{ .slot = last_view_cursor.slot() }); 183 } 184 } 185 186 // process the event into the views map 187 { 188 const views = try Repo.DB.HashMap(.read_write).init(views_cursor); 189 190 switch (event.data) { 191 .issue => |data| { 192 const event_id_to_issue_cursor = try views.putCursor(hash.hashInt(repo_opts.hash, "event-id->issue")); 193 const event_id_to_issue = try Repo.DB.HashMap(.read_write).init(event_id_to_issue_cursor); 194 195 const issue_cursor = try event_id_to_issue.putCursor(hash.hashInt(repo_opts.hash, &current_event_id_buffer)); 196 const issue = try Repo.DB.HashMap(.read_write).init(issue_cursor); 197 198 try putIfDifferent(issue, hash.hashInt(repo_opts.hash, "id"), &current_event_id_buffer); 199 try putIfDifferent(issue, hash.hashInt(repo_opts.hash, "title"), data.title); 200 try putIfDifferent(issue, hash.hashInt(repo_opts.hash, "description"), data.description); 201 }, 202 } 203 } 204 205 // the current event id is now the last one 206 last_event_id_maybe = current_event_id_buffer; 207 208 // prevent any of the data created above from being mutated by future iterations of this loop 209 try cursor.db.freeze(); 210 } 211 212 if (last_event_id_maybe) |*last_event_id| { 213 try haxy.put(hash.hashInt(repo_opts.hash, "last-event-id"), .{ .bytes = last_event_id }); 214 } 215 } 216 217 fn putIfDifferent(map: Repo.DB.HashMap(.read_write), key: hash.HashInt(repo_opts.hash), value: []const u8) !void { 218 if (try map.getCursor(key)) |value_cursor| { 219 var buffer: [4096]u8 = undefined; 220 const existing_value = try value_cursor.readBytes(&buffer); 221 if (std.mem.eql(u8, existing_value, value)) { 222 return; 223 } 224 } 225 226 try map.put(key, .{ .bytes = value }); 227 } 228 }; 229 230 try repo.core.db_file.lock(io, .exclusive); 231 defer repo.core.db_file.unlock(io); 232 233 // create a new transaction in the database that runs the above-defined Ctx function 234 const history = try Repo.DB.ArrayList(.read_write).init(repo.core.db.rootCursor()); 235 try history.appendContext(.{ .slot = try history.getSlot(-1) }, Ctx{ .events = events.items }); 236 237 // read the moment we just created 238 const moment_cursor = try history.getCursor(-1) orelse return error.NotFound; 239 const moment = try Repo.DB.HashMap(.read_only).init(moment_cursor); 240 241 const haxy_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy")) orelse return error.NotFound; 242 const haxy = try Repo.DB.HashMap(.read_only).init(haxy_cursor); 243 244 const event_id_to_views_cursor = try haxy.getCursor(hash.hashInt(repo_opts.hash, "event-id->views")) orelse return error.NotFound; 245 const event_id_to_views = try Repo.DB.HashMap(.read_only).init(event_id_to_views_cursor); 246 247 // make sure the issue from the first event was correctly edited 248 { 249 // get the last event id 250 const last_event_id_cursor = try haxy.getCursor(hash.hashInt(repo_opts.hash, "last-event-id")) orelse return error.NotFound; 251 var last_event_id_buffer: [evt.id_size]u8 = undefined; 252 _ = try last_event_id_cursor.readBytes(&last_event_id_buffer); 253 254 // get the latest views (the views generated by the last event id) 255 const views_cursor = try event_id_to_views.getCursor(hash.hashInt(repo_opts.hash, &last_event_id_buffer)) orelse return error.NotFound; 256 const views = try Repo.DB.HashMap(.read_only).init(views_cursor); 257 258 // get the map of issues 259 const event_id_to_issue_cursor = try views.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 260 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 261 262 // get the issue out of the map that was edited 263 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id)) orelse return error.NotFound; 264 const first_issue = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 265 266 // make sure the issue's description was correctly edited 267 const first_issue_description_cursor = try first_issue.getCursor(hash.hashInt(repo_opts.hash, "description")) orelse return error.NotFound; 268 const first_issue_description_value = try first_issue_description_cursor.readBytesAlloc(allocator, null); 269 defer allocator.free(first_issue_description_value); 270 try std.testing.expectEqualStrings(events_to_process[1].data.issue.description, first_issue_description_value); 271 } 272 } 273