Skip to content
zit Artifact [7f0fbc850e]
Login

Artifact [7f0fbc850e]

Artifact 7f0fbc850e842141d7796e6727b8c010e9a257eccffd8765e304bcb01f99da9b:


//! #87 — what a purge would remove, decided before anything is removed.
//!
//! `zit shun` answers "this content must never exist here again": permanent,
//! propagating, and correct once content has escaped. Purge answers the other
//! half — the `zit add .` that swept in a build tree, noticed before anyone
//! else has it — by removing the check-in and reclaiming its bytes while
//! keeping them recoverable in the local graveyard (`graveyard.zig`).
//!
//! This module computes the PLAN and nothing else. It opens nothing, writes
//! nothing, and reclaims nothing; `cmd/purge.zig` renders it and, only if the
//! operator confirms, hands its drop set to the same `rewriteAndSwap` that
//! `zit shun` uses. Separating the two is what makes the interesting property
//! testable without a repository on disk.
//!
//! THE PROPERTY: a purge must leave ZERO PHANTOMS. Dropping a blob a retained
//! manifest still references does not free anything — it converts held content
//! into a phantom the next `zit update` fails on and the next sync re-fetches,
//! which is worse than the bloat the operator was trying to remove. So the
//! drop set is not "what the named check-ins reference"; it is
//!
//!     (seed ∪ reachable(seed)) \ reachable(everything retained)
//!
//! and the plan is refused outright if that subtraction would take a named
//! check-in back out. Stating it as a set difference over a full-store walk,
//! rather than as a reference count maintained incrementally, is deliberate: a
//! count that drifts is silent, and this is the one operation where being
//! wrong destroys data.
//!
//! LEAF-ONLY, and by the strict definition. `zit leaves` lists OPEN leaves on
//! their branch — a check-in with a child on another branch is still a leaf
//! there, and a closed one is not listed at all. Purge needs the stricter
//! question: does ANY check-in in the DAG name this as a parent? Purging a
//! check-in with a child would orphan the child, so this module counts P-card
//! references directly rather than reusing that branch-scoped answer.

const std = @import("std");
const artifact = @import("artifact.zig");
const logstore = @import("logstore.zig");

const Rid = logstore.Rid;

pub const Error = error{
    OutOfMemory,
    Corrupt,
    Storage,
    /// The computed drop set does not remove every check-in that was named,
    /// and no per-target blocker explained why. Unreachable through any input
    /// this module knows how to describe, which is exactly why it is an ERROR
    /// rather than an assert: an assert compiles to nothing under
    /// `--release=fast`, and the thing it guards here is a destructive rewrite.
    /// Refusing on an inconsistency we cannot explain is the only safe arm.
    PlanIncomplete,
};

/// Why a named check-in cannot be purged. Every one of these is reported with
/// the operator's own token, and every one blocks the WHOLE plan: a purge that
/// silently did the possible half of what was asked would leave the operator
/// believing the rest had happened too.
pub const Blocker = enum {
    /// No artifact in this repository carries that name.
    not_found,
    /// The prefix names more than one artifact.
    ambiguous,
    /// The name resolves, but to a blob, a tag, or a wiki page — not a
    /// check-in. Purging those is a different operation with different
    /// consequences, so it is refused rather than guessed at.
    not_a_checkin,
    /// Some check-in names this one as a parent. Removing it would orphan that
    /// child: its P card would point at nothing, which is a phantom by another
    /// name. Purge the descendants first, or reach for `shun`.
    not_a_leaf,
    /// The checkout is standing on it. The one refusal with no override, for
    /// the same reason `zit shun` has it.
    current_checkout,
    /// Something retained still names it — a tag artifact that also tags
    /// check-ins we are keeping, or a cluster. Dropping it would leave that
    /// artifact pointing at absent content.
    still_named,

    pub fn describe(self: Blocker) []const u8 {
        return switch (self) {
            .not_found => "no such artifact",
            .ambiguous => "ambiguous prefix",
            .not_a_checkin => "not a check-in",
            .not_a_leaf => "not a leaf: a later check-in names it as a parent",
            .current_checkout => "the current checkout",
            .still_named => "a retained artifact still names it",
        };
    }
};

/// One name the operator supplied, and what became of it. Classified in place
/// the way `zit shun` classifies its targets, so every token keeps its own
/// diagnostic instead of collapsing into one count.
pub const Target = struct {
    name: []const u8,
    rid: Rid = 0,
    blocker: ?Blocker = null,
};

/// The artifacts a purge would remove, and what they weigh.
pub const Plan = struct {
    /// Persisted order, which is the order the rewrite will walk them in.
    drop: []const Rid,
    /// How the drop set breaks down, for the line the operator reads before
    /// confirming. `others` is blobs and submanifests — content that exists in
    /// this repository only because the named check-ins introduced it.
    checkins: usize = 0,
    controls: usize = 0,
    others: usize = 0,
    /// Stored bytes reclaimed: frame spans, not plaintext sizes, because the
    /// number an operator is deciding about is how much smaller the file gets.
    bytes: u64 = 0,
};

/// What one artifact points at. Built once per artifact for the whole store,
/// because both halves of the set difference need it and parsing the store
/// twice to answer the same question is how the two halves drift apart.
const Refs = struct {
    kind: ?artifact.ArtifactType,
    /// P cards: the DAG edges that decide leaf-ness.
    parents: []const Rid = &.{},
    /// B, F and S cards: the content this artifact NEEDS. Following these
    /// transitively is what "reachable" means here.
    content: []const Rid = &.{},
    /// T targets and M members: what this artifact SPEAKS ABOUT. A tag needs
    /// its target to exist to mean anything, but the target is not part of the
    /// tag, so these retain without being reachable content.
    names: []const Rid = &.{},
};

/// Classify every artifact in the store. `arena` owns the result.
///
/// An artifact that does not parse is a blob — that is exactly how the rest of
/// zit tells the two apart, and a blob points at nothing, so it gets an empty
/// entry rather than an error. A parse failure here is therefore never fatal.
fn classify(gpa: std.mem.Allocator, arena: std.mem.Allocator, log: *logstore.LogStore, all: []const Rid) Error![]Refs {
    const out = try arena.alloc(Refs, all.len);
    for (all, 0..) |rid, i| {
        out[i] = .{ .kind = null };
        // A phantom has no bytes here to parse; it also cannot be dropped,
        // since there is nothing of it to drop.
        if (log.isPhantom(rid)) continue;
        const bytes = log.content(gpa, rid) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return error.Corrupt,
        } orelse continue;
        defer gpa.free(bytes);

        // Parsed with `gpa`: the Manifest owns its own arena and frees it on
        // deinit, while the rid lists below live in `arena` and outlive it.
        var m = artifact.parse(gpa, bytes) catch continue; // a blob
        defer m.deinit();

        var parents: std.ArrayList(Rid) = .empty;
        var content: std.ArrayList(Rid) = .empty;
        var names: std.ArrayList(Rid) = .empty;

        // A P card carries the whole parent RUN, so the edges come from its own
        // re-tokenizer rather than one hash per card.
        for (m.p) |p| {
            var it = p.hashes();
            while (it.next()) |h| {
                if (try resolve(log, h)) |r| try parents.append(arena, r);
            }
        }
        if (m.b) |b| if (try resolve(log, b.hash)) |r| try content.append(arena, r);
        for (m.f) |f| if (f.hash) |h| if (try resolve(log, h)) |r| try content.append(arena, r);
        for (m.s) |s| if (try resolve(log, s.hash)) |r| try content.append(arena, r);
        // A T card with no target is the `*` form: it speaks about the
        // artifact carrying it, not about another one, so it names nothing.
        for (m.t) |tc| if (tc.target) |tgt| {
            if (try resolve(log, tgt)) |r| try names.append(arena, r);
        };
        for (m.m) |mc| if (try resolve(log, mc.hash)) |r| try names.append(arena, r);

        out[i] = .{
            .kind = m.kind,
            .parents = parents.items,
            .content = content.items,
            .names = names.items,
        };
    }
    return out;
}

/// A card hash to the rid it names, or null when this repository does not hold
/// it. The index is keyed by binary digest while cards carry hex, so the
/// lookup goes through the prefix resolver; a full-width hex name cannot be
/// ambiguous, but a malformed one is treated as naming nothing rather than
/// aborting a walk that is only trying to find out what is referenced.
fn resolve(log: *logstore.LogStore, hex: []const u8) Error!?Rid {
    return log.resolveHexPrefix(hex) catch return null;
}

const RidSet = std.AutoHashMapUnmanaged(Rid, void);

/// Everything `roots` needs, transitively, including the roots. Cycle-safe via
/// the visited set — an artifact graph should be acyclic, but a walk that
/// assumes it while deciding what to delete is a walk that hangs on a corrupt
/// store instead of reporting one.
fn reach(gpa: std.mem.Allocator, index: *const std.AutoHashMapUnmanaged(Rid, usize), refs: []const Refs, roots: []const Rid, out: *RidSet) Error!void {
    var stack: std.ArrayList(Rid) = .empty;
    defer stack.deinit(gpa);
    for (roots) |r| try stack.append(gpa, r);
    while (stack.pop()) |rid| {
        if ((try out.getOrPut(gpa, rid)).found_existing) continue;
        const at = index.get(rid) orelse continue;
        for (refs[at].content) |c| try stack.append(gpa, c);
    }
}

/// Decide what `targets` would remove. Returns null when any target is
/// blocked; the caller renders the blockers from `targets`, which this
/// function has classified in place.
///
/// `checkout_rid` is the check-in the working tree is standing on, or null in
/// a repository with no checkout (a bare server clone).
pub fn plan(
    gpa: std.mem.Allocator,
    arena: std.mem.Allocator,
    log: *logstore.LogStore,
    targets: []Target,
    checkout_rid: ?Rid,
) Error!?Plan {
    const all = try log.ridsInPersistedOrder(arena);
    const refs = try classify(gpa, arena, log, all);

    var index: std.AutoHashMapUnmanaged(Rid, usize) = .empty;
    defer index.deinit(gpa);
    try index.ensureTotalCapacity(gpa, @intCast(all.len));
    for (all, 0..) |rid, i| index.putAssumeCapacity(rid, i);

    // The DAG edges, counted over the whole store rather than asked of the
    // branch cache. See the module doc: purge needs "has no child at all".
    var children: std.AutoHashMapUnmanaged(Rid, std.ArrayListUnmanaged(Rid)) = .empty;
    defer {
        var it = children.valueIterator();
        while (it.next()) |v| v.deinit(gpa);
        children.deinit(gpa);
    }
    for (all, 0..) |rid, i| {
        if (refs[i].kind != .manifest) continue;
        for (refs[i].parents) |p| {
            const e = try children.getOrPut(gpa, p);
            if (!e.found_existing) e.value_ptr.* = .empty;
            try e.value_ptr.append(gpa, rid);
        }
    }

    // Classified in two passes, because leaf-ness is a property of the NAMED
    // SET rather than of one token. `zit purge checkins A B` where B is A's
    // child removes the tip of a branch and orphans nothing — refusing it
    // because A "has a child" would refuse the ordinary case this verb exists
    // for, an accidental commit noticed one commit later.
    var seed: RidSet = .empty;
    defer seed.deinit(gpa);
    var blocked = false;
    for (targets) |*tg| {
        if (tg.blocker != null) {
            blocked = true;
            continue;
        }
        const at = index.get(tg.rid) orelse {
            tg.blocker = .not_found;
            blocked = true;
            continue;
        };
        if (refs[at].kind != .manifest) {
            tg.blocker = .not_a_checkin;
            blocked = true;
            continue;
        }
        if (checkout_rid != null and checkout_rid.? == tg.rid) {
            tg.blocker = .current_checkout;
            blocked = true;
            continue;
        }
        try seed.put(gpa, tg.rid, {});
    }
    if (blocked) return null;

    // Now that the whole named set is known: a target is a leaf OF THIS PURGE
    // when every check-in naming it as a parent is going too.
    for (targets) |*tg| {
        const kids = children.get(tg.rid) orelse continue;
        for (kids.items) |c| {
            if (seed.contains(c)) continue;
            tg.blocker = .not_a_leaf;
            blocked = true;
            break;
        }
    }
    if (blocked) return null;

    // A tag artifact about a purged check-in goes with it — it is metadata
    // that becomes meaningless, and leaving it behind is what would dangle.
    // But only when EVERY check-in it names is going: a tag that also speaks
    // about something we keep is not ours to delete, and quietly dropping half
    // its meaning is worse than making the operator deal with it. Clusters are
    // never dropped this way; a cluster is a sync manifest, and one naming a
    // purged artifact blocks the plan below rather than being rewritten here.
    for (all, 0..) |rid, i| {
        if (refs[i].kind != .control) continue;
        if (refs[i].names.len == 0) continue;
        var any = false;
        var every = true;
        for (refs[i].names) |n| {
            if (seed.contains(n)) any = true else every = false;
        }
        if (any and every) try seed.put(gpa, rid, {});
    }

    var seed_roots: std.ArrayList(Rid) = .empty;
    defer seed_roots.deinit(gpa);
    var keep_roots: std.ArrayList(Rid) = .empty;
    defer keep_roots.deinit(gpa);
    for (all, 0..) |rid, i| {
        if (seed.contains(rid)) {
            try seed_roots.append(gpa, rid);
            continue;
        }
        // ONLY TOP-LEVEL ARTIFACTS RETAIN. A blob and a submanifest exist in a
        // repository because something else names them; they are content, not
        // reasons to keep content. Treating every retained artifact as a root
        // makes `reachable(retained)` the whole store minus the seed, and the
        // set difference then frees nothing but the named check-ins themselves
        // — a purge that reports success and reclaims no bytes, which is the
        // one outcome indistinguishable from working.
        if (refs[i].kind) |k| {
            if (k != .submanifest) try keep_roots.append(gpa, rid);
        }
    }

    var reach_seed: RidSet = .empty;
    defer reach_seed.deinit(gpa);
    try reach(gpa, &index, refs, seed_roots.items, &reach_seed);

    // Everything retained needs, transitively — the half that decides which
    // candidates are actually free. Note the roots are ALL retained artifacts,
    // not only retained check-ins: a retained tag, wiki page or cluster keeps
    // what it points at just as firmly.
    var reach_keep: RidSet = .empty;
    defer reach_keep.deinit(gpa);
    try reach(gpa, &index, refs, keep_roots.items, &reach_keep);

    // A retained artifact that NAMES a seed member without containing it —
    // the tag with mixed targets, the cluster. It is not caught by the content
    // walk above, because naming is not reachability, and it is exactly the
    // case where dropping would leave a live pointer to absent bytes.
    for (all, 0..) |rid, i| {
        if (seed.contains(rid)) continue;
        for (refs[i].names) |n| {
            if (!seed.contains(n)) continue;
            for (targets) |*tg| {
                if (tg.rid == n) tg.blocker = .still_named;
            }
            blocked = true;
        }
    }
    if (blocked) return null;

    var drop: std.ArrayList(Rid) = .empty;
    var out: Plan = .{ .drop = &.{} };
    for (all, 0..) |rid, i| {
        if (!reach_seed.contains(rid)) continue;
        if (reach_keep.contains(rid)) continue; // still referenced: not ours to free
        try drop.append(arena, rid);
        out.bytes += log.frameSpanOf(rid);
        switch (refs[i].kind orelse .submanifest) {
            .manifest => out.checkins += 1,
            .control => out.controls += 1,
            else => out.others += 1,
        }
    }

    // THE PROPERTY, RE-ASKED OF THE ANSWER. Every named check-in must have
    // survived the subtraction; if the retained side reached one, some
    // reference kind kept it alive that the checks above did not name, and the
    // right response is to refuse rather than to remove it anyway. This is the
    // backstop that makes "zero phantoms" a property of the OUTPUT rather than
    // a property of the cases we thought of.
    for (targets) |*tg| {
        if (reach_keep.contains(tg.rid)) {
            tg.blocker = .still_named;
            blocked = true;
        }
    }
    if (blocked) return null;

    out.drop = drop.items;
    if (out.checkins != targets.len) return error.PlanIncomplete;
    return out;
}

const t = std.testing;
const storage = @import("storage.zig");

/// A two-check-in repository with one blob each and one blob in common:
///
///     root ── root.txt, shared.txt
///       │
///     leaf ── leaf.txt, shared.txt      + a tag artifact naming leaf
///
/// The shared blob is what makes the set difference observable — a purge of
/// `leaf` that frees it has produced a phantom in `root`.
const Fixture = struct {
    tmp: std.testing.TmpDir,
    file: std.Io.File,
    fs: storage.FileStorage,
    log: logstore.LogStore,

    root: Rid,
    leaf: Rid,
    tag: Rid,
    shared_blob: Rid,
    root_only_blob: Rid,
    leaf_only_blob: Rid,

    fn deinit(self: *Fixture, gpa: std.mem.Allocator) void {
        self.log.deinit(gpa);
        self.file.close(std.testing.io);
        self.tmp.cleanup();
    }
};

const Named = struct { rid: Rid, hex: []const u8 };

/// Store `body` under its own content hash and hand back the rid and the hex
/// name the cards will carry.
fn putBlob(arena: std.mem.Allocator, log: *logstore.LogStore, body: []const u8) !Named {
    var digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(body, &digest, .{});
    return .{
        .rid = try log.putArtifact(&digest, body, null),
        .hex = try std.fmt.allocPrint(arena, "{x}", .{&digest}),
    };
}

/// Seal card text with its §6.2 Z self-checksum and store it. Built as text
/// rather than through a `Manifest` literal so each test states the artifact
/// it means in the form the format defines.
fn putArtifactText(arena: std.mem.Allocator, log: *logstore.LogStore, cards: []const u8) !Named {
    var z: [32]u8 = undefined;
    _ = artifact.selfChecksum(cards, &z);
    return putBlob(arena, log, try std.fmt.allocPrint(arena, "{s}Z {s}\n", .{ cards, z }));
}

/// Initializes IN PLACE: `fx.log` borrows a `StorageFile` pointing into
/// `fx.fs`, so the fixture must never be moved after the store is opened.
fn buildFixture(gpa: std.mem.Allocator, arena: std.mem.Allocator, fx: *Fixture) !void {
    const io = std.testing.io;
    fx.tmp = std.testing.tmpDir(.{});
    errdefer fx.tmp.cleanup();
    fx.file = try fx.tmp.dir.createFile(io, "repo", .{ .read = true });
    errdefer fx.file.close(io);
    fx.fs = storage.FileStorage.init(io, fx.file, 0);
    fx.log = try logstore.LogStore.open(gpa, fx.fs.storageFile(), 0);
    errdefer fx.log.deinit(gpa);

    const shared = try putBlob(arena, &fx.log, "bytes both check-ins name");
    const root_only = try putBlob(arena, &fx.log, "bytes only the root names");
    const leaf_only = try putBlob(arena, &fx.log, "bytes only the leaf names");

    const root = try putArtifactText(arena, &fx.log, try std.fmt.allocPrint(arena,
        \\C root
        \\D 2026-01-01T00:00:00.000
        \\F root.txt {s}
        \\F shared.txt {s}
        \\U dan
        \\
    , .{ root_only.hex, shared.hex }));

    const leaf = try putArtifactText(arena, &fx.log, try std.fmt.allocPrint(arena,
        \\C leaf
        \\D 2026-01-02T00:00:00.000
        \\F leaf.txt {s}
        \\F shared.txt {s}
        \\P {s}
        \\U dan
        \\
    , .{ leaf_only.hex, shared.hex, root.hex }));

    const tag = try putArtifactText(arena, &fx.log, try std.fmt.allocPrint(arena,
        \\D 2026-01-03T00:00:00.000
        \\T +sym-release {s}
        \\U dan
        \\
    , .{leaf.hex}));

    try fx.log.commit();
    fx.root = root.rid;
    fx.leaf = leaf.rid;
    fx.tag = tag.rid;
    fx.shared_blob = shared.rid;
    fx.root_only_blob = root_only.rid;
    fx.leaf_only_blob = leaf_only.rid;
}

test "purge: a leaf's exclusive blob is freed and its shared blob is not" {
    // The property the whole module exists for. Purging the leaf frees the
    // leaf's own blob and must NOT free the one `root` also names — that would
    // convert held content into a phantom in a check-in nobody asked to touch.
    // Its tag goes with it, because a tag about a removed check-in is what
    // would otherwise be left pointing at nothing.
    const gpa = t.allocator;
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var fx: Fixture = undefined;
    try buildFixture(gpa, arena, &fx);
    defer fx.deinit(gpa);

    var targets = [_]Target{.{ .name = "leaf", .rid = fx.leaf }};
    const p = (try plan(gpa, arena, &fx.log, &targets, null)) orelse {
        std.debug.print("unexpected blocker: {s}\n", .{@tagName(targets[0].blocker.?)});
        return error.TestUnexpectedResult;
    };
    try t.expectEqual(@as(usize, 1), p.checkins);
    try t.expectEqual(@as(usize, 1), p.controls);
    try t.expectEqual(@as(usize, 1), p.others); // leaf.txt's blob, and only it
    try t.expect(contains(p.drop, fx.leaf));
    try t.expect(contains(p.drop, fx.tag));
    try t.expect(contains(p.drop, fx.leaf_only_blob));
    try t.expect(!contains(p.drop, fx.shared_blob));
    try t.expect(!contains(p.drop, fx.root));
    try t.expect(!contains(p.drop, fx.root_only_blob));
    try t.expect(p.bytes > 0);
}

test "purge: the current checkout is refused" {
    const gpa = t.allocator;
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var fx: Fixture = undefined;
    try buildFixture(gpa, arena, &fx);
    defer fx.deinit(gpa);

    var targets = [_]Target{.{ .name = "leaf", .rid = fx.leaf }};
    try t.expect((try plan(gpa, arena, &fx.log, &targets, fx.leaf)) == null);
    try t.expectEqual(Blocker.current_checkout, targets[0].blocker.?);
}

test "purge: a blob named on the command line is not a check-in" {
    const gpa = t.allocator;
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var fx: Fixture = undefined;
    try buildFixture(gpa, arena, &fx);
    defer fx.deinit(gpa);

    var targets = [_]Target{.{ .name = "a blob", .rid = fx.shared_blob }};
    try t.expect((try plan(gpa, arena, &fx.log, &targets, null)) == null);
    try t.expectEqual(Blocker.not_a_checkin, targets[0].blocker.?);
}

test "purge: purging every check-in frees every blob including the shared one" {
    // The other end of the set difference: with nothing retained to reach it,
    // the shared blob IS free, and a plan that kept it would leave the operator
    // with bytes no artifact names — the bloat they were trying to remove.
    const gpa = t.allocator;
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var fx: Fixture = undefined;
    try buildFixture(gpa, arena, &fx);
    defer fx.deinit(gpa);

    // `root` is a leaf OF THIS PURGE only because `leaf` is named alongside
    // it — the case a per-token leaf test would have refused.
    var targets = [_]Target{
        .{ .name = "leaf", .rid = fx.leaf },
        .{ .name = "root", .rid = fx.root },
    };
    const p = (try plan(gpa, arena, &fx.log, &targets, null)) orelse {
        std.debug.print("unexpected blocker: {s}\n", .{@tagName(targets[1].blocker.?)});
        return error.TestUnexpectedResult;
    };
    try t.expectEqual(@as(usize, 2), p.checkins);
    try t.expect(contains(p.drop, fx.shared_blob));
    try t.expect(contains(p.drop, fx.root_only_blob));
    try t.expect(contains(p.drop, fx.leaf_only_blob));
    // Everything the fixture holds, so the store would be left empty.
    try t.expectEqual(@as(usize, 6), p.drop.len);
}

test "purge: naming the parent but not its child is refused" {
    const gpa = t.allocator;
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var fx: Fixture = undefined;
    try buildFixture(gpa, arena, &fx);
    defer fx.deinit(gpa);

    var targets = [_]Target{.{ .name = "root", .rid = fx.root }};
    try t.expect((try plan(gpa, arena, &fx.log, &targets, null)) == null);
    try t.expectEqual(Blocker.not_a_leaf, targets[0].blocker.?);
}

fn contains(haystack: []const Rid, needle: Rid) bool {
    for (haystack) |h| if (h == needle) return true;
    return false;
}