A campaign in Nakodo can be paused in three different ways: stop finding new creators, stop the email flow, or stop both. Nakodo can also pause a campaign itself, for instance when a Free account's month of contacts is used up, and our pricing page states a couple of those cases, including that extra campaigns are paused rather than deleted when a plan gets smaller.

From that one product idea comes a question the code has to answer in two completely different places. A React component renders a status dot and needs to know whether this campaign is still searching. A background job runs a query that has to pick which campaigns' searches to claim out of everyone's. The first has one row in memory. The second must not load any rows to find out.

So the rule is written twice, on purpose, and the two files sit next to each other.

src/lib/pause.ts 51 lines, no imports at all, 16 importers src/lib/pause-sql.ts 29 lines, imports drizzle and the schema, 10 importers 

The same sentence in two languages

// pause.ts export const isFinding = (c: CampaignState): boolean => c.status === "active" || pauseModeOf(c) === "emails"; // pause-sql.ts const pausedByUser = and(paused, isNull(campaigns.pauseReason)); export const findingSql: SQL = or(active, and(pausedByUser, eq(campaigns.pauseMode, "emails")))!; 

There are four pairs in these two files: finding, searching, emailing and starting conversations. Two more live elsewhere in the repo, and I will come to both. pause.ts has no imports, which is deliberate: it is used by client components, by server components, and by the schema's own types, so it cannot be allowed to drag a database client into a browser bundle. pause-sql.ts is the half that knows about columns, and its ten importers are all background work.

Reading the two together is also how you find out what the product actually decided. isEmailing is true for a campaign Nakodo paused itself, because the conversations already started are allowed to finish, while startsConversations is false for the same row, because nothing new may begin. In SQL that is two expressions that differ by one or branch:

export const emailingSql: SQL = or( active, and(paused, isNotNull(campaigns.pauseReason)), and(pausedByUser, eq(campaigns.pauseMode, "finding")), )!; export const startingSql: SQL = or(active, and(pausedByUser, eq(campaigns.pauseMode, "finding")))!; 

Where three-valued logic agrees with a default by luck

A user pause from before the modes existed has pause_mode null, and it means "both stopped". In TypeScript that is a nullish default:

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

In SQL nobody wrote a default at all. pause_mode = 'emails' against a null column is not false, it is null, and a null is not true, so the row falls out of findingSql exactly as the ?? "both" default intends. The two halves agree, and they agree for different reasons.

That only holds while the predicate is used positively. The moment you negate one in SQL, the null stops being harmless, because not null is still null. The surviving example of the pattern in this codebase is a different jsonb-backed rule:

export const notHeld: SQL = sql`not coalesce((${outreachThreads.offer}->>'held')::boolean, false)`; 

That coalesce is the whole lesson. A TypeScript predicate returns a boolean; a SQL predicate returns a boolean or nothing at all, and the nothing is only invisible until someone inverts it.

One rule exists only in SQL, and nobody noticed

pause-sql.ts exports five things, not four. The fifth is this:

// Checking results already found (scores, fit notes, comments): while either // half runs on the user's pause. Nakodo's own pauses leave results as they are. export const checkingSql: SQL = or(active, and(pausedByUser, inArray(campaigns.pauseMode, ["finding", "emails"])))!; 

There is no isChecking in pause.ts. The rule is used in five files of pipeline code and has no in-memory twin, because no screen has ever needed to say "this campaign is still checking what it found". That is a reasonable state of affairs and a slightly uncomfortable one: the file that is supposed to be the readable statement of the rules is missing one of them, and a future screen that needs it will either import the SQL version into a place it does not belong or write a fifth rule by hand.

The smallest pair is 14 lines

Automatic outreach only writes a first email once there is a proposal to write it from. Here is that question, asked both ways, in two files of 8 and 6 lines:

// offer.ts export const hasOffer = (s: Pick<OutreachSettings, "offer" | "ask" | "proposalSavedAt"> | null | undefined): boolean => Boolean(s?.proposalSavedAt || s?.offer?.trim() || s?.ask?.trim()); // offer-sql.ts export const hasOfferSql = (outreach: AnyPgColumn): SQL<boolean> => sql`(${outreach}->>'proposalSavedAt' is not null or coalesce(nullif(trim(${outreach}->>'offer'), ''), nullif(trim(${outreach}->>'ask'), '')) is not null)`; 

They read the same jsonb column, one through a parsed object and one through ->>. The TypeScript side gets trim() and truthiness for free; the SQL side needs nullif(trim(...), '') to get the same answer, because an empty string is a perfectly good non-null value in Postgres.

And they are not quite identical. proposalSavedAt is typed ?: string, and the two halves disagree about one value: an empty string is falsy in TypeScript and is not null in SQL. Nothing writes an empty string there today, so this is a latent difference rather than a bug, and the useful thing is that writing the pair out side by side is what makes it visible. The SQL version runs in three places, including the outreach runner; the TypeScript one runs on two screens.

The sixth pair builds a string instead of a boolean

Not every twin is a predicate. The job queue gives each piece of work a deduplication key so that one creator read, or one keyword search, can have at most one open job. The key is a joined string, and it is built both ways:

export const jobKey = (kind: KeyKind, ...parts: string[]) => [kind, ...parts].join(":"); // The same key built in SQL from columns, for "is there a job" checks. export const jobKeySql = (kind: KeyKind, ...parts: (SQL | AnyColumn)[]): SQL => sql.join([sql`${kind}`, ...parts], sql` || ':' || `); 

One is used when inserting a job, with values in hand. The other is used inside a not exists over the jobs table, where the parts are columns of the rows being considered, so the key has to be assembled by Postgres per row. The two share a KeyKind union, which is the only thing keeping the prefixes honest: a new kind of work is a type error in both functions at once.

The rules that cannot be written twice

Some predicates have no TypeScript twin because they cannot have one. The Free plan shows the best 20 good fits of a campaign and blurs the rest, which in SQL is a row_number() over (partition by ...) inside the predicate. Expressing that in memory means loading every row of a table to find out which 20 qualify, which is the exact work the predicate exists to avoid. The same is true of the job queue's deduplication, where the key is assembled in SQL so that a partial unique index can enforce it.

That is the real dividing line. A rule about one row is written twice. A rule about a row's place among other rows is written once, in the only language that can ask the question.

There is a third option, and the codebase uses it in the one place where a twin would have been genuinely hard to keep correct. Whether a creator can still be contacted is a condition over several tables, written once as SQL. The code that needs the answer in TypeScript does not reimplement it; it asks Postgres to evaluate the predicate and hand back the boolean:

.select({ id: channels.id, usable: sql<boolean>`${contactableSql(channels.id)}` }) 

That costs a round trip and guarantees one implementation, which is the right trade whenever the caller is already doing a query. The four pause rules do not qualify, because the whole point of the TypeScript half is to answer with no query at all, from a row a server component has already loaded.

Nothing in the repo compares a pair

Here is the part I cannot defend. The repo has 62 test files, all run by node --test through tsx, and not one of them connects to a database. Four import from @/db, and all four are type-only imports:

src/lib/pipeline/widen.test.ts:3:import type { Brief } from "@/db/schema"; src/lib/analysis/score.test.ts:3:import type { Brief } from "@/db/schema"; src/lib/proposal/proposal.test.ts:3:import type { OutreachSettings } from "@/db/schema"; src/lib/analysis/rationale.test.ts:3:import type { Brief, ScoreBreakdown } from "@/db/schema"; 

So pause.test.ts tests the TypeScript half thoroughly, with assertions like "pausing the finding keeps the email flow going", and the SQL half has never been tested at all. If somebody adds a fourth pause mode and updates only one file, the suite stays green and the app starts disagreeing with itself: a campaign whose status dot says it is searching, whose searches are never claimed.

The test that would catch it is not hard, and it needs a real Postgres rather than a mock, because the whole point is the three-valued logic. A throwaway cluster is a one-liner, and I have used one before for exactly this kind of question:

initdb -D /tmp/pg >/dev/null && pg_ctl -D /tmp/pg -o "-p 55432 -k /tmp/pg" -l /tmp/pg/log start 

Insert the twelve meaningful combinations of status, pause mode and pause reason, run each *Sql predicate as a WHERE, run each TypeScript predicate over the same twelve rows in Node, and assert the two sets of ids match. Twelve rows, five predicates, one assertion per predicate. It is on the list, which is a phrase doing a lot of work in this sentence.

Until then the thing holding the pairs together is that both files are short, both are commented with the name of their twin, and they sit next to each other in the directory listing. That is not a test. It is at least a design: when a rule has to exist in two languages, make the two versions as easy to read side by side as you can, and never let one of them be the one that happens to be convenient.

Some of what these rules produce is visible from outside: how it works describes the stages a campaign moves through, and pricing states the limits that cause Nakodo to pause one on its own.