---
title: "My first patch to Ergo"
description: "Deleting a DM in Slirc brought it back to life. The trail led into Ergo itself, and to my first patch to the IRC server Slirc runs on."
pubDate: "2026-10-03"
heroImage: "/slirc-ergo-hero.png"
series: "Building Slirc"
part: 3
---

I deleted a DM in Slirc and watched it leave the list. Then I clicked the person again to start a fresh conversation, and there it was. The whole conversation, back on screen.

The delete went through, but the conversation was still there... 🤔

I've fixed a lot of bugs in Slirc these past weeks, but this one made me change [Ergo](https://ergo.chat/), the IRC server Slirc runs on. My first patch to it.

## What's a DM in Slirc

**IRC is the server**, that's the whole point. Your chat is IRC underneath, so nothing is trapped in a proprietary database, and you can point Halloy or Senpai at the same channels. To make that work, I had to translate modern chat ideas into IRC primitives.

The obvious way to DM someone on IRC is `/msg`, and it only works when both of you are online at the same time. Close the laptop and the message never arrives. I wanted the WhatsApp behaviour. I write when I want, and the conversation is still there when they come back. Private channels turned out to be the best way I could find to get that.

A DM is not a private message. It is a **real private channel** with two or more members, two for a DM and more for a group chat, `#dm-ada-wolff` or similar. That is what buys you history, threads, reactions, media, and other clients in the same room.

The cost shows up exactly when you delete something. Deleting a DM unregisters the channel and removes the pair mapping. Ergo does that part for free. What Ergo **does not do** is throw away the channel's history. History is stored by channel name, and the name is deterministic. Delete `#dm-ada-wolff`, recreate it, and the old messages are still sitting there under the same name.

The first half of the fix was client-side: when you delete a conversation, purge its local copy in IndexedDB. A DM's identity is the other person's account id, so the key never changes, and the browser was happily replaying a transcript that should not exist anymore.

The second half needed the server to actually clear Ergo's history. I wrote that, using `HistServ DELETE`, the supported way to delete a message over IRC. It worked on a live channel. Then I pointed it at an already-deleted channel and got a surprise.

## "Successfully deleted message"

The history database had **171 rows** for a channel that no longer existed. I asked Ergo to delete them and it kept saying the same thing:

> Successfully deleted message

The count went from 171 to 107 to 57, then mostly stopped. Every delete was acknowledged and almost none of them were taking effect. I kept reading that reply, thinking I was holding it wrong.

At that point you have two options. You can build machinery around the problem, or you can go read the server.

## Reading the server

Ergo is open source, so I cloned it at `v2.19.1` and looked. The delete path took a message id and looked it up with `LIMIT 1`, which only ever removes one copy.

A message sent to more than one target keeps a single id, but Ergo stores a history row per target. The lookup took the first row it found, deleted that copy, and reported success, leaving the others behind.

The delete reported success and removed one copy. The rest stayed exactly where they were. It showed up in my case because Slirc keeps deleting and re-creating DMs as you add and remove people, so duplicate ids accumulate quietly until you go looking.

## The patch

The small fix deletes every history row that matches the message id, because the id names the message, not the channel. I implemented it for **SQLite, MySQL and PostgreSQL**, and added a regression test with one message id stored under two targets, asserting both copies are gone. I am new to this codebase, so upstream may well know better.

Five files, one of them the new test.

<div class="code-glimpse">
<input type="checkbox" id="ergo-patch-diff" class="glimpse-toggle">
<div class="glimpse-body">

```diff
diff --git a/irc/sqlite/history.go b/irc/sqlite/history.go
@@ -725,20 +725,33 @@ func (s *SQLite) DeleteMsgid(msgid string) (err error) {
 	}
 
 	ctx := context.Background()
-
-	_, id, _, _, err := s.lookupMsgid(ctx, msgid, true)
+	rows, err := s.db.QueryContext(ctx, `SELECT id FROM history WHERE msgid = ?;`, msgid)
 	if err != nil {
-		if err == sql.ErrNoRows {
-			return history.ErrNotFound
-		}
-		return
+		return err
 	}
+	defer rows.Close()
 
-	err = s.deleteHistoryIDs(ctx, []uint64{id})
-	if err != nil {
+	var ids []uint64
+	for rows.Next() {
+		var id uint64
+		if err = rows.Scan(&id); err != nil {
+			return err
+		}
+		ids = append(ids, id)
+	}
+	if err = rows.Err(); err != nil {
+		return err
+	}
+	if err = rows.Close(); err != nil {
+		return err
+	}
+	if len(ids) == 0 {
+		return history.ErrNotFound
+	}
+	if err = s.deleteHistoryIDs(ctx, ids); err != nil {
 		return fmt.Errorf("couldn't delete msgid: %w", err)
 	}
-	return
+	return nil
 }
 
 func (s *SQLite) LoadMsgid(msgid string) (channel string, item history.Item, err error) {
diff --git a/irc/sqlite/delete_test.go b/irc/sqlite/delete_test.go
new file mode 100644
@@ -0,0 +1,46 @@
+//go:build sqlite && (linux || darwin || freebsd || windows)
+
+package sqlite
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/ergochat/ergo/irc/history"
+	"github.com/ergochat/ergo/irc/logger"
+	"github.com/ergochat/ergo/irc/utils"
+)
+
+func TestDeleteMsgidDeletesAllCopies(t *testing.T) {
+	log, err := logger.NewManager(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	db, err := NewSQLiteDatabase(log, Config{DatabasePath: t.TempDir() + "/history.db"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+
+	item := history.Item{}
+	item.Message = utils.SplitMessage{Msgid: "shared-msgid", Time: time.Now()}
+	for _, target := range []string{"#other", "#unregistered"} {
+		if err := db.AddChannelItem(target, item, ""); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	if err := db.DeleteMsgid("shared-msgid"); err != nil {
+		t.Fatal(err)
+	}
+
+	var remaining int
+	if err := db.db.QueryRowContext(context.Background(), `
+		SELECT count(*) FROM history WHERE msgid = ?`, "shared-msgid").Scan(&remaining); err != nil {
+		t.Fatal(err)
+	}
+	if remaining != 0 {
+		t.Fatalf("history copies remain after delete: %d", remaining)
+	}
+}
```

</div>
<label for="ergo-patch-diff" class="glimpse-control"><span class="glimpse-more">Show the whole diff</span><span class="glimpse-less">Hide the diff</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></label>
</div>

Along the way I was tempted to write a middleware service for all the things Ergo's API does not expose. History deletion is only one of them. I am glad I did not. Ergo already gives you IRC and an oper interface, and it owns its own storage. A service in front of that would be another Ergo client and another writer, with membership drifting between them. The honest answer was to fix the server I already depend on.

I would never have found that if Ergo had been a black box.

## Do I know Go?

Not really. I can read it, and I had to learn enough of Ergo's history model to trust the fix. That is the part I value: I get to work in a codebase I would never have opened on my own, and understand a bit more of it when I'm done. This patch was planned and implemented with GPT-6 Luna at high thinking, then I checked the reasoning and the diff myself.

## What's next

- The patch lives on a branch, and the fork is up at [bitbonsai/ergo](https://github.com/bitbonsai/ergo). Whether it goes upstream is a separate question, with its own review bar.
- **October 6th:** pre-launch. The waiting list gets into app.slirc.org.
- **October 12th:** the source code goes public.

I would rather ask whether upstream wants this than keep a delete button that quietly does half the job. When I delete something, I want it gone, not hidden and not mostly gone.

[Join the waiting list](https://slirc.org/#waitlist), and see you on the 6th.
