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