fix(campaigns): store DM button order so links stop swapping and saves stop losing them - #65
Merged
Conversation
…s stop losing them Which tracked link is the first DM button was inferred from ORDER BY createdAt. A campaign's links are usually written in one request, so they share a createdAt, and Postgres returns tied rows in whatever order its sort and the rows' place on disk produce. Two visible bugs came out of that: - Duplicated campaigns could show their buttons swapped: the second URL on the first button, and the first URL on the second button titled "Primary campaign link". - Saving a campaign could delete its first link. The save updated the first link, then read the links again, and because the update had moved that row on disk, took it for the second link and wrote the second URL over it. The DM worker used the same order, so real DMs could send the wrong URL under a button. Store the order in a new TrackedLink.position column and read every link list with one shared order (position, then createdAt and id as tie breakers), in the dashboard API, the DM worker, reports and duplicate. Create, save and duplicate write positions explicitly. Saving now reads the links once, changes them by id, renumbers positions, and runs with the campaign update in one transaction. The update locks the campaign row first, so two saves of the same campaign cannot both create the same missing link. The migration adds the column and backfills existing links oldest first, with id breaking ties. It is safe to re-run, because Prisma does not apply a migration file atomically.
|
@mhd64real is attempting to deploy a commit to the diwenne's projects Team on Vercel. A member of the Team first needs to authorize it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What users see
A campaign with two link buttons ("And then, they will get") can break in two ways:
The DM worker reads links in the same order, so commenters can also receive a DM where a button opens the wrong URL.
Root cause
Nothing stores which link is button 1 and which is button 2. Every reader (dashboard API, DM worker, reports, duplicate, save) inferred it from
ORDER BY "createdAt" ASC.When links are written in one request (create with both links, or duplicate), they get the same
createdAt. Postgres returns tied rows in whatever order its sort and the rows' physical position produce, so the order changes as rows move on disk. EveryUPDATEmoves a row.WHERE "automationId" IN (...) ORDER BY "createdAt". Once edits have scattered rows on disk, tied pairs come back either way round.links[1]as the second link. With a tie, the moved first link now came back second, so the second URL and title were written over it.Reproduction (before this change)
On Postgres 16, with the app's own queries:
createdAton both links.[primary URL, second URL]into[second URL, second URL]. This happened every time on a sequential scan, which is what small self-hosted databases use.The fix
TrackedLink.position(int, default 0) stores the button order.TRACKED_LINK_ORDERinlib/tracking/link-order.tsis the one order every reader uses:position, thencreatedAt, thenid. The tie breakers keep reads deterministic for rows an older build writes during a deploy. It's used in the dashboard API, all three DM worker queries, reports, and duplicate.buildInitialCampaignLinks), duplicate (numbered from the order read), and save.syncCampaignLinksinlib/campaigns/links.ts) reads the links once, before any write. It changes links by id, then renumbers positions, which also repairs links saved before this change. It runs in one$transactionwith the campaign update. That update locks the campaign row first, so two saves of the same campaign run one after the other and cannot both create the same missing link. It also means a save can no longer land half applied.Migration and rollout
20260917160000_tracked_link_positiondoes two things:ADD COLUMN IF NOT EXISTS "position" INTEGER NOT NULL DEFAULT 0. On Postgres 11+ this is metadata only, with no table rewrite.COLLATE "C") breaking ties. Ids generated in one request increase in the order the links were written. On real Prisma-generated data, id order matched the intended order for 73 of 73 tied pairs.Safe to re-run. Prisma applies a migration file statement by statement, not in a transaction. I tested this: when the backfill failed, the column had already been committed. Wrapping the file in
BEGIN/COMMITmade Prisma reportcurrent transaction is abortedinstead of the real error. So the file is idempotent instead:prisma migrate resolve --rolled-back 20260917160000_tracked_link_positionfollowed by the next deploy re-runs it from the top. I verified that recovery path end to end.Measured on Postgres 16:
prisma migrate deployon existing data (150 links, 74 tied pairs, one already corrupted by the save bug)updatedAtuntouchedADD COLUMN6 ms, backfill 1.06 s, 200,000/200,000 correctprisma migrate diff(migrated DB vsschema.prisma)Mixed versions during a deploy (web and worker deploy separately):
duplicateCampaignand worker query from a checkout of main with its own generated client. Its writes get position 0 and read back in the right order through the tie breakers. The next save from the new build assigns real positions.P2022.processJobrethrows it, so BullMQ retries after 5 and then 15 minutes. That is the same exposure the last several migrations already had: main's worker fails identically (InstagramAccount.providerdoes not exist) against a database missing20260908163000_zernio_provider. Deploy order is unchanged, sincevercel-build(ormigrate deployin the Dokploy start command) applies migrations.Tests
Unit (in
npm test):__tests__/campaign-links.test.ts: save and create against an in-memory table. Covers unchanged saves, repairing tied positions, adding, removing, promoting the second link, a third link, other campaigns' links left alone, and the read happening once before any write.__tests__/campaign-duplicate.test.ts: copies are numbered 0, 1, 2 even when the original's positions are tied.__tests__/dm-worker.test.ts: the pinned worker query now expects the new order.Real Postgres (
__tests__/tracked-link-order.db.test.ts, 12 tests):These are skipped unless
TEST_DATABASE_URLis set, since CI has no database. They build the schema fromprisma/migrationsin a throwaway Postgres schema, seed legacy rows in scrambled physical order, apply the migration, and then drive the realGET/POST/PATCHroute handlers andduplicateCampaign. Connections are pinned to sequential scans, the plan small instances get, so tie-order bugs show up deterministically rather than depending on the planner.To check that these tests really catch the bugs, I reintroduced each one and confirmed the suite fails:
createdAtonlyThe suite with the fix passed 10 of 10 consecutive runs.
I can add a Postgres service to CI so the database suite runs on every PR, if you want it.
Behavior changes
Not recoverable
Links already overwritten by the save bug keep the second URL on both rows. The original first URL was not stored anywhere, so this change cannot restore it.