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