╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────╮ │src/event/User.zig│ ╰──────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │64 │ │65 try name_to_user_id.put(hash.hashInt(hash_kind, event.name), .{ .bytes = event_id }); │ │66 │ │66 - // first time we've seen this user: append its id to the ordered list │ │67 - // the users view paginates through (a delete leaves a tombstone here). │ │67 + // first time we've seen this user: record its creation timestamp and add it │ │68 + // to the ordered map the users view paginates through │ │69 if (existing_cursor_maybe == null) { │ │69 - const user_list_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "user-list")); │ │70 - const user_list = try DB.ArrayList(.read_write).init(user_list_cursor); │ │71 - try user_list.append(.{ .bytes = event_id }); │ │70 + try user.put(hash.hashInt(hash_kind, "created-ts"), .{ .uint = created_ts }); │ │71 + │ │72 + const timestamp_to_user_id_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "timestamp->user-id"));│ │73 + const timestamp_to_user_id = try DB.SortedMap(.read_write).init(timestamp_to_user_id_cursor); │ │74 + const order_key = evt.orderKey(created_ts, event_id); │ │75 + try timestamp_to_user_id.put(&order_key, .{ .bytes = event_id }); │ │76 } │ │77 } else { │ │78 // read the user's name so we can drop its name->id index entry │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │80 const existing_user = try DB.HashMap(.read_only).init(existing_cursor); │ │81 const existing_event = try evt.read(@This(), DB, hash_kind, arena, existing_user); │ │82 _ = try name_to_user_id.remove(hash.hashInt(hash_kind, existing_event.name)); │ │83 + │ │84 + // drop it from the ordered map using its recorded creation timestamp │ │85 + if (try existing_user.getCursor(hash.hashInt(hash_kind, "created-ts"))) |created_ts_cursor| { │ │86 + const stored_ts = try created_ts_cursor.readUint(); │ │87 + const timestamp_to_user_id_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "timestamp->user-id"));│ │88 + const timestamp_to_user_id = try DB.SortedMap(.read_write).init(timestamp_to_user_id_cursor); │ │89 + const order_key = evt.orderKey(stored_ts, event_id); │ │90 + _ = try timestamp_to_user_id.remove(&order_key); │ │91 + } │ │92 } │ │93 │ │94 if (!try event_id_to_user.remove(user_key)) return error.EventNotFound; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │213 updated.enable_ansi = !user.enable_ansi; │ │214 try evt.commitAndConsume(repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{.{│ │215 .id = std.fmt.bytesToHex(user_id[0..evt.event_id_size].*, .lower), │ │216 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │217 .event = .{ .user = updated }, │ │218 }}); │ │219 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/test/event.zig│ ╰──────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │805 const event_id_to_user = try Repo.DB.HashMap(.read_only).init(event_id_to_user_cursor); │ │806 │ │807 try std.testing.expect(null == try event_id_to_user.getCursor(hash.hashInt(repo_opts.hash, &user_event_id))); │ │808 + } │ │809 +} │ │810 + │ │811 +test "repos and users paginate in creation order" { │ │812 + const io = std.testing.io; │ │813 + const allocator = std.testing.allocator; │ │814 + const temp_dir_name = "temp-event-order"; │ │815 + │ │816 + const cwd = std.Io.Dir.cwd(); │ │817 + var temp_dir_or_err = cwd.openDir(io, temp_dir_name, .{}); │ │818 + if (temp_dir_or_err) |*temp_dir| { │ │819 + temp_dir.close(io); │ │820 + try cwd.deleteTree(io, temp_dir_name); │ │821 + } else |_| {} │ │822 + var temp_dir = try cwd.createDirPathOpen(io, temp_dir_name, .{}); │ │823 + defer cwd.deleteTree(io, temp_dir_name) catch {}; │ │824 + defer temp_dir.close(io); │ │825 + │ │826 + const cwd_path = try std.process.currentPathAlloc(io, allocator); │ │827 + defer allocator.free(cwd_path); │ │828 + const work_path = try std.fs.path.join(allocator, &.{ cwd_path, temp_dir_name }); │ │829 + defer allocator.free(work_path); │ │830 + │ │831 + const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; │ │832 + const Repo = rp.Repo(.xit, repo_opts); │ │833 + var repo = try Repo.init(io, allocator, .{ .path = work_path }); │ │834 + defer repo.deinit(io, allocator); │ │835 + │ │836 + var arena = std.heap.ArenaAllocator.init(allocator); │ │837 + defer arena.deinit(); │ │838 + │ │839 + var prng = std.Random.DefaultPrng.init(std.testing.random_seed); │ │840 + const user_id = evt.EventWithId.randomId(prng.random()); │ │841 + var repo_ids: [4][evt.event_id_size]u8 = undefined; │ │842 + for (&repo_ids) |*id| id.* = evt.EventWithId.randomId(prng.random()); │ │843 + │ │844 + var pw_buf: [evt.User.password_hash_max_len]u8 = undefined; │ │845 + const pw = try evt.User.hashPassword("pw", &pw_buf, io); │ │846 + │ │847 + // asserts the timestamp->repo-id map holds exactly `expected` ids in that order │ │848 + const Check = struct { │ │849 + fn order( │ │850 + comptime DB: type, │ │851 + comptime hash_kind: hash.HashKind, │ │852 + moment: DB.HashMap(.read_only), │ │853 + expected: []const *const [evt.event_id_size]u8, │ │854 + ) !void { │ │855 + const cursor = try moment.getCursor(hash.hashInt(hash_kind, "timestamp->repo-id")) orelse return error.NotFound; │ │856 + const map = try DB.SortedMap(.read_only).init(cursor); │ │857 + try std.testing.expectEqual(expected.len, try map.count()); │ │858 + for (expected, 0..) |id, i| { │ │859 + const kv = (try map.getIndexKeyValuePair(@intCast(i))) orelse return error.NotFound; │ │860 + var buf: [evt.event_id_size]u8 = undefined; │ │861 + _ = try kv.value_cursor.readBytes(&buf); │ │862 + try std.testing.expectEqualSlices(u8, id, &buf); │ │863 + } │ │864 + } │ │865 + }; │ │866 + │ │867 + // one user, then four repos, each with its own creation timestamp (user@100, │ │868 + // repos@101..104) │ │869 + const events = [_]evt.EventWithId{ │ │870 + .{ .id = std.fmt.bytesToHex(user_id, .lower), .timestamp = 100, .event = .{ .user = .{ .name = "alice", .display_name = "Alice", .email = "alice@example.test", .password_hash = pw } } },│ │871 + .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 101, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "d0", .enable_issue = true } } }, │ │872 + .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 102, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo1", .description = "d1", .enable_issue = true } } }, │ │873 + .{ .id = std.fmt.bytesToHex(repo_ids[2], .lower), .timestamp = 103, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo2", .description = "d2", .enable_issue = true } } }, │ │874 + .{ .id = std.fmt.bytesToHex(repo_ids[3], .lower), .timestamp = 104, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo3", .description = "d3", .enable_issue = true } } }, │ │875 + }; │ │876 + try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &events); │ │877 + │ │878 + { │ │879 + const moment = try evt.currentMoment(repo_opts, &repo); │ │880 + try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[1], &repo_ids[2], &repo_ids[3] }); │ │881 + │ │882 + // the single user shows up in its own ordered map │ │883 + const ucur = try moment.getCursor(hash.hashInt(repo_opts.hash, "timestamp->user-id")) orelse return error.NotFound; │ │884 + const umap = try Repo.DB.SortedMap(.read_only).init(ucur); │ │885 + try std.testing.expectEqual(1, try umap.count()); │ │886 + } │ │887 + │ │888 + // delete repo1 -> dense order (no tombstone) │ │889 + try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &[_]evt.EventWithId{ │ │890 + .{ .id = std.fmt.bytesToHex(repo_ids[1], .lower), .timestamp = 200, .event = .{ .repo = null } }, │ │891 + }); │ │892 + { │ │893 + const moment = try evt.currentMoment(repo_opts, &repo); │ │894 + try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[2], &repo_ids[3] }); │ │895 + } │ │896 + │ │897 + // update repo0 at a later timestamp -> keeps its original slot │ │898 + try evt.commitAndConsume(repo_opts, io, allocator, &repo, .{ .kind = .head, .name = "haxy/meta" }, &[_]evt.EventWithId{ │ │899 + .{ .id = std.fmt.bytesToHex(repo_ids[0], .lower), .timestamp = 300, .event = .{ .repo = .{ .user_id = &user_id, .name = "repo0", .description = "updated", .enable_issue = true } } }, │ │900 + }); │ │901 + { │ │902 + const moment = try evt.currentMoment(repo_opts, &repo); │ │903 + try Check.order(Repo.DB, repo_opts.hash, moment, &.{ &repo_ids[0], &repo_ids[2], &repo_ids[3] }); │ │904 + │ │905 + // the value really was updated │ │906 + const e2r_cur = try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->repo")) orelse return error.NotFound; │ │907 + const e2r = try Repo.DB.HashMap(.read_only).init(e2r_cur); │ │908 + const rc = try e2r.getCursor(hash.hashInt(repo_opts.hash, &repo_ids[0])) orelse return error.NotFound; │ │909 + const rm = try Repo.DB.HashMap(.read_only).init(rc); │ │910 + const re = try evt.read(evt.Repo, Repo.DB, repo_opts.hash, &arena, rm); │ │911 + try std.testing.expectEqualStrings("updated", re.description); │ │912 } │ │913 } │ │914 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │174 for (user_data, 0..) |u, i| { │ │175 events_to_consume[i] = .{ │ │176 .id = std.fmt.bytesToHex(user_ids[i], .lower), │ │177 + // stepped timestamps so the seeded users/repos list in a stable order│ │178 + .timestamp = @intCast(i + 1), │ │179 .event = .{ │ │180 .user = .{ │ │181 .name = u.name, │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────╮ │190 for (repo_data, 0..) |r, i| { │ │191 events_to_consume[user_data.len + i] = .{ │ │192 .id = std.fmt.bytesToHex(repo_event_ids[i], .lower),│ │193 + .timestamp = @intCast(user_data.len + i + 1), │ │194 .event = .{ │ │195 .repo = .{ │ │196 .user_id = &user_ids[r.user_index], │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────────╮ │src/ui/Home/Repos.zig│ ╰─────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │32 │ │33 const empty: Self = .{ .repos = &.{}, .owner_names = &.{}, .after = after, .next_after = null }; │ │34 │ │35 - // the ordered repo-list (oldest first); absent until the first repo exists. │ │36 - const repo_list_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "repo-list")) orelse return empty; │ │37 - const repo_list = try DB.ArrayList(.read_only).init(repo_list_cursor); │ │38 - const count = try repo_list.count(); │ │35 + // the repos ordered by creation time (oldest first); absent until the first │ │36 + // repo exists. keyed by [timestamp][event-id], value = event-id. │ │37 + const timestamp_to_repo_id_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "timestamp->repo-id")) orelse return empty;│ │38 + const timestamp_to_repo_id = try DB.SortedMap(.read_only).init(timestamp_to_repo_id_cursor); │ │39 + const count = try timestamp_to_repo_id.count(); │ │40 │ │41 const event_id_to_repo_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->repo")) orelse return empty; │ │42 const event_id_to_repo = try DB.HashMap(.read_only).init(event_id_to_repo_cursor); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │43 │ │43 - // read the window [after, after+page_size) by index — a direct O(log n) │ │44 - // seek per entry, never scanning the whole table. ids whose repo has been │ │45 - // deleted (tombstones) are skipped. │ │44 + // read the window [after, after+page_size) by index — a direct O(log n) seek│ │45 + // per entry, never scanning the whole table. deletes remove their entry (no │ │46 + // tombstones), so the window is dense. │ │47 const end = @min(after + page_size, count); │ │48 var i = after; │ │49 while (i < end) : (i += 1) { │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │49 - const id_cursor = try repo_list.getCursor(@intCast(i)) orelse continue; │ │50 + const id_kv = try timestamp_to_repo_id.getIndexKeyValuePair(@intCast(i)) orelse continue; │ │51 var event_id: [evt.event_id_size]u8 = undefined; │ │51 - _ = try id_cursor.readBytes(&event_id); │ │52 + _ = try id_kv.value_cursor.readBytes(&event_id); │ │53 const repo_cursor = try event_id_to_repo.getCursor(hash.hashInt(hash_kind, &event_id)) orelse continue;│ │54 const repo_map = try DB.HashMap(.read_only).init(repo_cursor); │ │55 const repo_event = try evt.read(evt.Repo, DB, hash_kind, arena, repo_map); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────────╮ │src/ui/Home/Users.zig│ ╰─────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │28 │ │29 var users: std.ArrayList(evt.User.Safe) = .empty; │ │30 │ │31 - // the ordered user-list (oldest first); absent until the first user exists. │ │32 - const user_list_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "user-list")) orelse │ │31 + // the users ordered by creation time (oldest first); absent until the first │ │32 + // user exists. keyed by [timestamp][event-id], value = event-id. │ │33 + const timestamp_to_user_id_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "timestamp->user-id")) orelse│ │34 return .{ .users = &.{}, .after = after, .next_after = null }; │ │34 - const user_list = try DB.ArrayList(.read_only).init(user_list_cursor); │ │35 - const count = try user_list.count(); │ │35 + const timestamp_to_user_id = try DB.SortedMap(.read_only).init(timestamp_to_user_id_cursor); │ │36 + const count = try timestamp_to_user_id.count(); │ │37 │ │38 const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->user")) orelse │ │39 return .{ .users = &.{}, .after = after, .next_after = null }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯