DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Pause became a question with three answers, so we wrote the rules twice and only one copy has tests

A while back I wrote about why a paused Nakodo campaign still answers its email. Short version: a campaign that paused because it hit its monthly limit must still reply to a creator who writes back, because stopping mid-conversation is rude to a human being who is not a party to our billing. So the pause carries a pauseReason column, and the system's pauses behave differently from the user's.

Then users asked for something that broke the model. They did not want to pause a campaign. They wanted to pause half of it.

The two halves of a campaign run independently. One looks for creators or businesses that fit the brief. The other writes, sends, follows up, answers and introduces. People wanted to stop the first and keep the second ("I have enough leads, work through them"), and people wanted to stop the second and keep the first ("we are out of stock for three weeks, keep looking, send nothing").

So Pause stopped being a verb and became a question with three answers. The menu reads:

  • Stop finding new creators. Emails carry on with the ones already found and the conversations under way.
  • Stop the email flow. Nakodo keeps finding creators, but nothing goes out: no first emails, follow-ups, answers or introductions.
  • Stop both. Nothing new is searched and nothing goes out until you resume.

Three columns, not one enum

The obvious modelling instinct is an enum with every state in it: active, paused_finding, paused_emails, paused_both, paused_limit, paused_plan. We did not do that, and the reason is ownership. The state is three columns:

status: text(),                                        // "draft" | "active" | "paused" | ...
pauseReason: text({ enum: ["outreach_limit", "plan"] }),
pauseMode: text({ enum: PAUSE_MODES }),                // "finding" | "emails" | "both"
Enter fullscreen mode Exit fullscreen mode

pauseMode is the user's answer to a question. pauseReason is the system's record of why it intervened. They are set by different actors for different reasons, and squashing them into one column means every write has to know about the other actor's values. Keeping them apart costs one function that collapses the three columns into the single question everything else wants to ask:

export function pauseModeOf(c: CampaignState): PauseMode | null {
  if (c.status !== "paused") return null;
  if (c.pauseReason) return "finding";
  return c.pauseMode ?? "both";
}
Enter fullscreen mode Exit fullscreen mode

Three lines, three decisions, all three worth stating.

Line one is why there is no paused state hiding in pauseMode: the mode is meaningless unless the campaign is paused, so the function returns null rather than making every caller remember to check status first.

Line two is precedence, and it is the one that would be a bug if it were the other way round. If the system paused the campaign, the user's stored mode is ignored entirely and the answer is finding. A campaign that ran out of monthly allowance has stopped looking for new people and has absolutely not stopped replying to the ones it already wrote to. Had the mode been checked first, a user who once chose "stop both" and resumed would, months later, have their replies silently swallowed when they hit the Free limit.

Line three is a migration that is not a migration. Campaigns paused before modes existed have pauseMode = null. We could have backfilled them, but a backfill has to pick a value and we would have been guessing at intent. ?? "both" is not a guess: before there was a choice, a pause stopped everything, so null already means "both" and the function can simply say so. The old rows need no write at all, and the column's nullability now carries real information rather than being a schema accident.

Four predicates, because there are four different questions

export const isFinding = (c: CampaignState): boolean =>
  c.status === "active" || pauseModeOf(c) === "emails";

export const isSearching = (c: CampaignState & { enoughLeadsAt: Date | null }): boolean =>
  isFinding(c) && c.enoughLeadsAt === null;

export const isEmailing = (c: CampaignState): boolean =>
  c.status === "active" || pauseModeOf(c) === "finding";

export const startsConversations = (c: CampaignState): boolean =>
  c.status === "active" || (c.status === "paused" && !c.pauseReason && c.pauseMode === "finding");
Enter fullscreen mode Exit fullscreen mode

isFinding and isEmailing are mirror images, which is the pleasing part: pausing the emails leaves the finding on and vice versa.

isSearching is narrower than isFinding for a reason unrelated to pausing, which I will write about separately: a campaign with plenty of good leads waiting stops starting new searches by itself, while still processing what it has already found.

startsConversations is the one that cannot be derived from the other three. It is isEmailing minus the system pauses. A campaign at its monthly limit sends follow-ups, answers questions and makes introductions, and starts nothing new. "May this campaign send email" and "may this campaign send a first email to somebody new" are two different questions, and a single isEmailing boolean standing in for both is exactly how a billing limit turns into a leak.

The tests read as the specification, which is what you want from a file like this:

test("pausing the finding keeps the email flow going", () => {
  const c = paused("finding");
  assert.equal(isFinding(c), false);
  assert.equal(isEmailing(c), true);
  assert.equal(startsConversations(c), true);
});

test("Nakodo's own pauses stop finding and finish conversations without starting new ones", () => {
  for (const reason of ["outreach_limit", "plan"]) {
    const c = paused(null, reason);
    assert.equal(pauseModeOf(c), "finding");
    assert.equal(isFinding(c), false);
    assert.equal(isEmailing(c), true);
    assert.equal(startsConversations(c), false);
  }
});
Enter fullscreen mode Exit fullscreen mode

pause.ts imports nothing. Not the schema, not the database, not a logger. That is deliberate: the same rules are needed in a client component rendering the pause banner, in a server action, in the cron pipeline and in a unit test, and a single import of anything server-flavoured would have ended that.

Then we wrote all of it a second time, in SQL

Here is the uncomfortable part. The pipeline does not ask these questions about a campaign it already has in memory. It asks "which jobs may run right now", across every campaign in the database, in one statement that locks the rows it claims. You cannot call a TypeScript predicate from inside FOR UPDATE SKIP LOCKED.

So there is a second file, pause-sql.ts, which is the same rules as Drizzle fragments:

const active = eq(campaigns.status, "active");
const paused = eq(campaigns.status, "paused");
const pausedByUser = and(paused, isNull(campaigns.pauseReason));

// isFinding(): searching for new creators or businesses.
export const findingSql: SQL = or(active, and(pausedByUser, eq(campaigns.pauseMode, "emails")))!;

// isEmailing(): first emails and follow-ups go out.
export const emailingSql: SQL = or(
  active,
  and(paused, isNotNull(campaigns.pauseReason)),
  and(pausedByUser, eq(campaigns.pauseMode, "finding")),
)!;
Enter fullscreen mode Exit fullscreen mode

Duplicated logic is a smell and this is duplicated logic. The alternatives were worse. Loading every campaign and filtering in JavaScript gives up row-level locking and the fairness ordering the claimer depends on. Pushing the predicates into a Postgres function means the rules live in migrations, where they are harder to test and much harder to change. Writing them twice, in two files that name each other in comments, with one of the two covered by tests, was the trade we took knowingly.

And then the claimer composes them into one condition, keyed on what the job actually does rather than on which campaign it belongs to:

const campaignAllows = or(
  and(inArray(jobs.type, SEARCH_JOBS), searchingSql),
  and(inArray(jobs.type, EMAIL_JOBS), emailingSql),
  and(notInArray(jobs.type, [...SEARCH_JOBS, ...EMAIL_JOBS]), checkingSql),
);
Enter fullscreen mode Exit fullscreen mode

Three job classes: the ones that look for new people, the ones that send mail, and everything else (scoring what has been found, refreshing profiles, maintenance). Each class gets its own gate. A campaign with its emails paused still has its found creators scored, because the user will want to look at them when they resume.

The two copies are not symmetrical, and that is where the bugs live

pause.ts exports four predicates. pause-sql.ts exports five conditions. The extra one is checkingSql, which has no TypeScript twin because only the query ever needs it. If you are going to duplicate rules, be honest in the comments about which direction the duplication runs, because "these two files are the same" is a lie that costs somebody an afternoon.

The genuinely interesting divergence is three-valued logic. In TypeScript, c.pauseMode ?? "both" handles null and you move on. In SQL, pauseMode = 'emails' against a NULL column is not false, it is NULL, and NULL propagates. For a legacy row paused with no mode, findingSql evaluates to FALSE OR (TRUE AND NULL), which is NULL. Negate it and you get NULL, which matches nothing, so a WHERE NOT findingSql quietly skips exactly the rows you were trying to find.

Which is why the one place that negates the fragment does this:

await db
  .update(campaigns)
  .set({ enoughLeadsAt: null })
  .where(and(isNotNull(campaigns.enoughLeadsAt), sql`not coalesce(${findingSql}, false)`, scope));
Enter fullscreen mode Exit fullscreen mode

not coalesce(findingSql, false). The COALESCE is not decoration. Without it, a campaign that was paused before pause modes existed would hold its stale state forever, and the symptom would be a campaign that never starts searching again with nothing in any log to explain it.

A TypeScript predicate and the same predicate in SQL are not the same function. They differ precisely where a column can be null, which is also precisely where your oldest rows live.

Where to see it

The user-facing consequence is published on the pricing page, in the limits FAQ at nakodo.app/pricing. It says that Free counts by the month and pauses your campaigns when the month's creators are contacted, that Pro and Business count by the day across all your campaigns, and then the sentence that the whole pauseReason branch exists to make true:

Conversations already started always carry on, whatever the count.

The three-way pause menu itself is behind a sign-in at nakodo.app, on any campaign's header. The second line of each option is the honest description of which half keeps running, and it is generated from the same PauseMode union as everything above.

Top comments (0)