DEV Community

Cover image for Ticket keys in Firestore: issued once, never reused after delete
Panth Patel
Panth Patel

Posted on Originally published at panth.vardayinitech.in

Ticket keys in Firestore: issued once, never reused after delete

I split the ticket id from the ticket key so that tickets could move between boards, and then I took moving tickets out of the product.

The split stayed. A key like ENG-42 gets pasted into WhatsApp messages, emails, GitHub PR titles and other tickets, and every one of those has to keep pointing at the right ticket for as long as the link exists, including after the ticket is gone.

I'm Panth, and I lead the software team at Oizom. This is from ticket-tracker, my open-source board, written to replace a tracker called TeamTrack. Its decisions page lists the choices that are expensive to reverse. This is D4.

The decision

Chosen A generated, immutable ticketId, a key field, and a keys index that keeps old keys as redirects
Rejected The key as the document id, which is what TeamTrack did with TICKET_ID
Why Keys change when a ticket moves. Links in emails, WhatsApp messages, GitHub PRs and other tickets' #refs must keep working after that.

So a ticket lives at boards/{boardId}/tickets/{ticketId}, and ENG-42 is a field on it. The key also gets a document of its own at the top level:

// keys/{ticketKey}: '#ENG-42' → the ticket it names
export const KeyIndexSchema = z.object({
  ticketId: TicketIdSchema,
  boardId: BoardIdSchema,
  /** The key itself, equal to the doc id. */
  current: TicketKeySchema,
  deleted: z.boolean().optional(),
});
Enter fullscreen mode Exit fullscreen mode

current is the redirect: an old key's row can name the key the ticket has now. Exact-key search follows one hop of it.

Then the move was dropped

Phase 1 built moving a ticket to another board: copy the ticket, its thread, activity and files to the other board, give it a new key, and redirect the old one.

Decision D13 took it out. Moving is machinery (option mapping per field, assignees who aren't on the new board, a queued copy job, the key redirects) for something that happens rarely in a personal tool, and it was the most dangerous write in the system. Now a ticket stays on the board it was created on. To connect work across boards, you link the tickets or #reference them. A ticket filed on the wrong board gets closed and filed again, and the new one can reference the old.

So the reason written next to D4 no longer happens. Nothing writes a redirect today. The id and the key stayed separate anyway, because the same split does three other jobs.

1. References store the id, not the key

When you type #ENG-42 in a comment, the editor stores a ticketRef node with the ticket's id. The key in that node is only a hint for rendering:

case 'ticketRef': {
  const id = String(n.attrs?.ticketId ?? '');
  if (id && !refs.includes(id)) refs.push(id);
  const hint = typeof n.attrs?.key === 'string' && n.attrs.key ? n.attrs.key : 'ticket';
  line += `#${opts.keyOf?.(id) ?? hint}`;
  return;
}
Enter fullscreen mode Exit fullscreen mode

The server derives each message's refs as a list of ticket ids. Mentions of people work the same way: a mention stores a uid and shows the current display name, so a rename never breaks it. TeamTrack stored mentions as [@Name](mailto:…) links in Markdown and keyed assignees by email, so a change of address was a data migration.

2. A key is issued once

Keys come from a counter on the board document, inside the same transaction that creates the ticket. Not from a query for the highest number:

const start = board.nextNumber;
tx.update(ref, { nextNumber: start + ticketIds.length });
return ticketIds.map((ticketId, i) => {
  const number = start + i;
  const key = ticketKey(board.key, number);
  tx.create(keyRef(key), { ticketId, boardId, current: key });
  return { key, number };
});
Enter fullscreen mode Exit fullscreen mode

tx.create fails if the document already exists. A key that was ever handed out, whether it now points at a live ticket, a deleted one or (once) a moved one, can't be overwritten. The transaction fails instead.

The price is in the comment above that code: the board document takes about one sustained write a second, so one new ticket per second per board. Bulk creation takes a range with a single counter bump.

3. Delete leaves a tombstone

Deleting a ticket removes it from the links and references of other tickets, takes it off watchers' lists, deletes the ticket document and its thread, and keeps the key:

// Tombstone: kept forever, never reissued.
const tomb: KeyIndex = k
  ? { ...k, deleted: true }
  : { ticketId, boardId, current: ticket.key, deleted: true };
tx.set(keyRefs[i]!, tomb);
Enter fullscreen mode Exit fullscreen mode

After that, #ENG-42 in an old comment says the ticket was deleted. With the key as the document id, deleting the ticket deletes the only record that the key was ever used. If that key were ever issued again, every old link would open the new ticket as if nothing had happened. The comment in the schema says it more directly: the row is kept "so an old link says 'this ticket is gone' instead of landing on somebody else's work".

Deleting a whole board does the same for every ticket on it, and its board key stays claimed too. Board keys like ENG are claimed across the whole app, first come first served, like a username. That is what lets /t/ENG-42 and #ENG-42 mean one ticket without saying whose board it is.

Who reads the index

Everything that starts from a key goes through keys/, not through a board:

  • the REST and MCP doors, when an agent or a script asks for ENG-42
  • the GitHub hook, which finds keys in branch names, PR titles, commit messages and comments, and posts a line in each ticket's thread
  • Markdown coming in through REST or MCP, where #ENG-42 is turned into a ticket id before it is stored
  • the search box, for an exact key

A tombstoned key resolves to nothing for all of them. Over REST and MCP it is a 404, and so is a key on a board the caller can't read.

What it cost

One extra document per ticket, and one extra read whenever something arrives by key instead of by id: a pasted key, a webhook, a search. Links between tickets store only the id, so finding one looks on the same board first, which is almost always right, and falls back to the keys index, whose rows carry the board.

What I'd keep from this for any system with human-readable ids: let the name be a field, give it its own index, issue it once, and never delete the index row. The move that justified D4 is gone, and the split still earns its place on delete alone.

Have you ever reused an id after a delete, and found out from a link that opened the wrong thing?

Top comments (0)