╭ changed ───────────────╮ │src/event/Assignment.zig│ ╰────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────╮ │25 │ │26 // the index a thread's view reads │ │27 pub const thread_id_to_assignment_id_set_key = "thread-id->assignment-id-set"; │ │28 +// the issues each email is assigned to, keyed like the issue status sets │ │29 +pub const assignee_to_issue_id_set_key = "assignee->issue-id-set"; │ │30 │ │31 +// the longest email an assignment holds │ │32 +pub const email_max_len = 254; │ │33 + │ │34 // the id of the one assignment an email can hold on a thread │ │35 pub fn idOf(thread_id: *const [evt.event_id_size]u8, email: []const u8) [evt.event_id_size]u8 {│ │36 var hasher = std.crypto.hash.sha2.Sha256.init(.{}); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────╮ │42 } │ │43 │ │44 fn emailValid(email: []const u8) bool { │ │40 - if (email.len == 0) return false; │ │45 + if (email.len == 0 or email.len > email_max_len) return false;│ │46 for (email) |ch| { │ │47 if (std.ascii.isWhitespace(ch)) return false; │ │48 } │ ╰──────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │104 _ = try thread_assignments.remove(&order_key); │ │105 } else { │ │106 try thread_assignments.put(&order_key); │ │107 + } │ │108 + │ │109 + // only an issue is indexed by assignee │ │110 + const thread_id = try evt.parseEventId(&record.event.thread_id); │ │111 + const issue = (try evt.readRecordSubset(evt.Issue, struct { created_order: u64, removed: bool }, DB, hash_kind, haxy_moment.readOnly(), arena, &thread_id)) orelse return;│ │112 + try indexIssue(DB, hash_kind, haxy_moment, record.event.email, &evt.orderKeyDesc(issue.created_order, &thread_id), !record.removed and !issue.removed); │ │113 +} │ │114 + │ │115 +// put or drop an issue's order key in `email`'s assignee set, pruning an emptied set │ │116 +pub fn indexIssue( │ │117 + comptime DB: type, │ │118 + comptime hash_kind: hash.HashKind, │ │119 + haxy_moment: DB.HashMap(.read_write), │ │120 + email: []const u8, │ │121 + order_key: []const u8, │ │122 + active: bool, │ │123 +) !void { │ │124 + const by_assignee = try DB.HashMap(.read_write).init(try haxy_moment.putCursor(hash.hashInt(hash_kind, assignee_to_issue_id_set_key))); │ │125 + const email_key = hash.hashInt(hash_kind, email); │ │126 + if (active) { │ │127 + const issues = try DB.SortedSet(.read_write).init(try by_assignee.putCursor(email_key)); │ │128 + try issues.put(order_key); │ │129 + return; │ │130 } │ │131 + if (null == try by_assignee.getCursor(email_key)) return; │ │132 + const issues = try DB.SortedSet(.read_write).init(try by_assignee.putCursor(email_key)); │ │133 + _ = try issues.remove(order_key); │ │134 + if (0 == try issues.count()) _ = try by_assignee.remove(email_key); │ │135 } │ │136 │ │137 // assign `email` to a thread and return the event id. `repo` must be writable. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/event/Issue.zig│ ╰───────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │159 const label_set = try DB.SortedSet(.read_write).init(label_set_cursor); │ │160 try label_set.put(&order_key); │ │161 } │ │162 + } │ │163 + │ │164 + // the assignee sets hold the issue while it exists, whatever its status │ │165 + const id_hex = std.fmt.bytesToHex(event_id.*, .lower); │ │166 + for (try evt.Assignment.load(hash_kind, arena, haxy_moment.readOnly(), &id_hex)) |email| { │ │167 + try evt.Assignment.indexIssue(DB, hash_kind, haxy_moment, email, &order_key, !record_to_write.removed);│ │168 } │ │169 } │ │170 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────╮ │src/search.zig│ ╰──────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │69 // stop at their page size. │ │70 pub fn Query(comptime DB: type) type { │ │71 return struct { │ │72 - // one per typed word; empty when nothing can match │ │72 + // one per typed word, then one per required set. a term with no │ │73 + // postings matches nothing, so neither does the query. │ │74 terms: std.ArrayList(Term), │ │75 // the key the next result starts from, plus room for the one byte that│ │76 // steps past a returned key │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │148 if (!std.mem.startsWith(u8, indexed, word)) break; │ │149 try postings.append(aa, .{ .set = try DB.SortedSet(.read_only).init(pair.value_cursor) });│ │150 } │ │150 - // a word nothing starts with rules out every document │ │151 - if (postings.items.len == 0) return .{ .terms = .empty }; │ │151 + // a word nothing starts with leaves an empty term, which rules │ │152 + // out every document │ │153 try terms.append(aa, .{ .postings = postings.items }); │ │154 } │ │155 return .{ .terms = terms }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │156 } │ │157 │ │158 // narrow the results to the doc keys in `set`. it is one more term, │ │158 - // with that set as its only posting. a query nothing can match stays │ │159 - // empty, since requiring a set would turn it into every doc key in it. │ │159 + // with that set as its only posting. │ │160 pub fn require(self: *Self, aa: std.mem.Allocator, set: DB.SortedSet(.read_only)) !void {│ │161 - if (self.terms.items.len == 0) return; │ │161 const postings = try aa.alloc(Posting, 1); │ │162 postings[0] = .{ .set = set }; │ │163 try self.terms.append(aa, .{ .postings = postings }); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │171 } │ │172 │ │173 pub fn next(self: *Self) !?[]const u8 { │ │174 + // with no terms at all nothing narrows the results, so none are listed│ │175 if (self.terms.items.len == 0) return null; │ │176 if (self.returned) { │ │177 // doc keys are at most max_doc_key_len, so appending a byte │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────────╮ │src/search_thread.zig│ ╰─────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │41 try srch.replace(DB, index, allocator, key, old_text, new_text); │ │42 } │ │43 │ │44 -// the doc keys matching `query_text`, in key order, narrowed to `filter` when│ │45 -// it is keyed the same way │ │44 +// the doc keys matching `query_text`, in key order, narrowed to each non-null│ │45 +// set in `filters`, which must be keyed the same way │ │46 pub fn query( │ │47 comptime DB: type, │ │48 comptime hash_kind: hash.HashKind, │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │50 haxy_moment: DB.HashMap(.read_only), │ │51 aa: std.mem.Allocator, │ │52 query_text: []const u8, │ │53 - filter: ?DB.SortedSet(.read_only), │ │53 + filters: []const ?DB.SortedSet(.read_only), │ │54 ) !srch.Query(DB) { │ │55 - const cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, indexKey(kind))) orelse return .{ .terms = .empty };│ │56 - const index = try DB.SortedMap(.read_only).init(cursor); │ │57 - var results = try srch.Query(DB).init(index, aa, query_text); │ │58 - if (filter) |set| try results.require(aa, set); │ │55 + // no index means no words match, which one empty term expresses │ │56 + var results: srch.Query(DB) = .{ .terms = .empty }; │ │57 + if (try haxy_moment.getCursor(hash.hashInt(hash_kind, indexKey(kind)))) |cursor| { │ │58 + results = try srch.Query(DB).init(try DB.SortedMap(.read_only).init(cursor), aa, query_text); │ │59 + } else { │ │60 + try results.terms.append(aa, .{ .postings = &.{} }); │ │61 + } │ │62 + for (filters) |filter| if (filter) |set| try results.require(aa, set); │ │63 return results; │ │64 } │ │65 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯