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