Skip to content

feat(broadcast): per-wave audience export — pick N, stamp, push to fresh Resend segment#3462

Merged
koala73 merged 2 commits into
mainfrom
feat/audience-wave-export
Apr 27, 2026
Merged

feat(broadcast): per-wave audience export — pick N, stamp, push to fresh Resend segment#3462
koala73 merged 2 commits into
mainfrom
feat/audience-wave-export

Conversation

@koala73

@koala73 koala73 commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

The sustainable per-wave primitive for the PRO-launch ramp. One CLI command per send, no manual Resend dashboard work, no risk of re-emailing prior waves. Replaces #3453's "you'd have to keep building segments forever" tax.

npx convex run broadcast/audienceWaveExport:assignAndExportWave \
  '{"waveLabel":"wave-2","count":500}'
# → returns { segmentId, assigned, ... }

# Then existing flow:
npx convex run broadcast/sendBroadcast:createProLaunchBroadcast \
  '{"segmentId":"<returned>","nameSuffix":"wave-2"}'

Why this design (recap)

Resend's API can't subset/sample/exclude at broadcast time (verified docs):

  • POST /broadcasts accepts segment_id only — no inclusion/exclusion filters.
  • POST /segments accepts name only — segments are membership lists, not query-defined.

So progressive waves require tracking membership in Convex (where dedup math already runs). The proLaunchWave? field on registrations (added in #3453) is the source of truth; this PR adds the action that picks-N-and-stamps automatically.

What assignAndExportWave does

  1. Pre-flight — refuse if waveLabel already has stamped rows (operator picks unique label per wave; prevents accidental double-stamp).
  2. Pick — page registrations.paginate (1000/page), filter by the same dedup rules as audienceExport.ts:
    • non-empty normalizedEmail
    • not in emailSuppressions
    • not in customers (paid)
    • proLaunchWave is undefined (not in any prior wave)
      Random-sample count via reservoir sampling (Algorithm R) — fair sample, single pass, O(N) memory.
  3. Stamp — patch each picked row with proLaunchWave = waveLabel + proLaunchWaveAssignedAt.
  4. Create segmentPOST /segments named pro-launch-${waveLabel}.
  5. Push — call the shared upsertContactToSegment helper (same two-step pattern audienceExport.ts uses for the global-contact-422 quirk).
  6. Return { segmentId, assigned, linkedExisting, alreadyExists, failed, underfilled }.

underfilled: true when the eligible pool is smaller than count (e.g., asked for 500 but only 320 unstamped left after past waves). Operator should treat this as "the waitlist is drained for this ramp tier."

Companion refactor — extracted Resend helpers

convex/broadcast/_resendContacts.ts (NEW) owns the Resend HTTP layer:

  • RESEND_API_BASE, USER_AGENT constants
  • isDuplicateContactError heuristic (matches the 422 duplicate shape)
  • UpsertOutcome type
  • upsertContactToSegment (the two-step POST /contacts + fallback POST /contacts/{email}/segments/{id} that handles "global contact exists, segments field not applied on duplicate" — encoded once, reused by both exporters)
  • createSegment (NEW — used by the wave action to mint per-wave segments)

audienceExport.ts now imports these instead of carrying inline copies. No behaviour change on the full-audience path; just dedup.

Files

File Role
convex/broadcast/audienceWaveExport.ts (NEW) The action + its supporting internal queries/mutation
convex/broadcast/_resendContacts.ts (NEW) Shared Resend helpers
convex/broadcast/audienceExport.ts Inline helpers replaced with imports from _resendContacts.ts
convex/_generated/api.d.ts Codegen regen — exposes the new wave action via internal.broadcast.audienceWaveExport.*

Test plan

  • npx tsc --noEmit -p convex/tsconfig.json clean
  • Sentry-coverage lint guard clean (155 files)
  • Pre-push hooks pass (typecheck, biome, sentry-coverage, edge bundle, version sync)
  • After merge + Convex deploy: dry-run the action with count: 1 to verify the full path (pick → stamp → segment-create → push) works end-to-end without committing to a real wave size
  • Run wave-2 (500): expect { assigned: 500, ... }, pro-launch-wave-2 segment in Resend dashboard with 500 contacts

Operational guarantees

  • No double-emails: the prior-wave skip in audienceExport.ts (added in chore(broadcast): backfill proLaunchWave stamps for canary-250 contacts #3453) plus the _hasWaveLabel pre-flight here means an accidental re-run is a no-op error, not a duplicate send.
  • Random sample: spreads ESP / signup-cohort distribution for clean deliverability reads.
  • Idempotent failure modes: if a partial run dies between stamp and Resend push, the stamped rows act as a permanent "do not pick again" marker — the operator picks a new waveLabel for the next attempt and the partially-stamped contacts roll into the next wave's pool naturally.

…esh Resend segment

The sustainable per-send primitive for the PRO-launch ramp. Replaces
manual dashboard sub-segmenting with one CLI command per wave; the
existing canary-250 stamps already in registrations naturally exclude
yesterday's recipients from being picked again.

  npx convex run broadcast/audienceWaveExport:assignAndExportWave \
    '{"waveLabel":"wave-2","count":500}'
  # → returns { segmentId, assigned, ... }

  # Then existing flow:
  npx convex run broadcast/sendBroadcast:createProLaunchBroadcast \
    '{"segmentId":"<returned>","nameSuffix":"wave-2"}'

What it does:
1. Refuse if waveLabel already has stamped rows (operator picks unique
   label per wave; prevents accidental double-stamping).
2. Page registrations.paginate (1000/page), apply same dedup rules as
   audienceExport.ts (empty / suppressed / paid / already-in-prior-wave).
   Reservoir-sample N via Algorithm R — fair sample, single pass,
   O(N) memory.
3. Stamp each picked row with proLaunchWave + assignedAt via the
   shared _stampWaveByNormalizedEmail mutation (mirrors the
   canary-250 backfill action).
4. Create a fresh Resend segment via POST /segments named
   `pro-launch-${waveLabel}`.
5. Push picked contacts via the shared upsertContactToSegment helper
   (same two-step pattern audienceExport already uses — handles the
   "global contact exists, segments field not applied on duplicate
   422" Resend API quirk).
6. Return { segmentId, assigned, linkedExisting, alreadyExists, failed,
   underfilled }.

Companion refactor — extracted Resend helpers to a shared module:
  - `_resendContacts.ts` (NEW): RESEND_API_BASE, USER_AGENT,
    isDuplicateContactError, UpsertOutcome, upsertContactToSegment,
    and createSegment.
  - `audienceExport.ts`: replaced its inline copies with imports from
    the new module. No behaviour change; just dedup.

Why Resend can't do this natively: verified against Resend docs —
POST /broadcasts accepts segment_id only (no exclude/sample/limit
params), POST /segments accepts name only (segments are membership
lists, not query-defined via API). Progressive waves require tracking
membership somewhere; Convex is the right source of truth since dedup
math already runs there.

Convex codegen regenerated and committed (api.d.ts now includes
audienceWaveExport's internal mutation/query/action). Convex
typecheck (`tsc -p convex/tsconfig.json`) clean. Sentry-coverage lint
guard clean.
@vercel

vercel Bot commented Apr 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 27, 2026 11:26am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a per-wave audience export action (assignAndExportWave) that reservoir-samples N un-stamped registrants, stamps them with a wave label, creates a fresh Resend segment, and pushes the contacts — plus a companion refactor that centralises Resend HTTP helpers into _resendContacts.ts. The extraction and shared helper logic are clean; one correctness issue stands out in the stamp mutation.

  • _stampWaveByNormalizedEmail can silently overwrite an existing wave label. The mutation only short-circuits for the same label (idempotency) but not for a different one. Two concurrent assignAndExportWave calls with different labels that both paginate before either starts stamping will select the same un-stamped email; the second stamp wins, and the contact lands in both Resend segments → double-email. The PR's "No double-emails" guarantee depends on this never happening, but there is no defense-in-depth guard inside the mutation.

Confidence Score: 3/5

Not safe to merge as-is; the stamp mutation can silently overwrite a prior wave's label under concurrent runs, breaking the double-email guarantee.

One P1 finding (overwrite race in _stampWaveByNormalizedEmail) is a present correctness defect that directly contradicts a stated operational guarantee; three P2s do not compound the score but reinforce that the stamp path needs more care.

convex/broadcast/audienceWaveExport.ts — specifically the _stampWaveByNormalizedEmail mutation handler and the stamp loop in assignAndExportWave.

Important Files Changed

Filename Overview
convex/broadcast/audienceWaveExport.ts New per-wave export action; P1 race condition in _stampWaveByNormalizedEmail (can overwrite a different wave label), P2 issues with non-integer count validation, sequential stamp loop, and duplicated internal queries.
convex/broadcast/_resendContacts.ts New shared Resend HTTP helpers extracted cleanly; two-step upsert, duplicate-error heuristic, and segment creation all look correct.
convex/broadcast/audienceExport.ts Inline helpers replaced with imports from _resendContacts.ts; no behaviour change on the full-audience path.
convex/_generated/api.d.ts Codegen regen adding the two new modules; straightforward.

Reviews (1): Last reviewed commit: "feat(broadcast): per-wave audience expor..." | Re-trigger Greptile

Comment on lines +216 to +229
});
return { result: "stamped" as const };
},
});

type WaveExportStats = {
waveLabel: string;
segmentId: string;
segmentName: string;
// How many registrations were eligible (passed all dedup filters)
// and seen by the reservoir. May be > or = to `assigned`.
poolSize: number;
// Newly-stamped + pushed to Resend segment.
assigned: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stamp mutation silently overwrites a different wave's label

_stampWaveByNormalizedEmail only short-circuits when the row already has the same waveLabel; it doesn't guard against an existing different wave label. If two assignAndExportWave calls with different labels (e.g., wave-2 and wave-3) are started within the same paginate window, both select the same un-stamped email, then both stamp it sequentially — the second write overwrites the first. The contact ends up in both Resend segments and receives two emails, directly violating the "No double-emails" operational guarantee.

The fix is to add an "already in a different wave" guard in the mutation:

if (row.proLaunchWave && row.proLaunchWave !== waveLabel) {
  return { result: "alreadyInDifferentWave" as const };
}

And then count / warn on that outcome in the action loop instead of overwriting silently.

Comment on lines +252 to +256
{ waveLabel, count },
): Promise<WaveExportStats> => {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 count validation doesn't reject non-integer values

v.number() allows floats, and the guard Number.isFinite(count) && count > 0 passes for values like 1.5 or 499.9. A fractional count yields a reservoir with a non-integer size, so buf.length < this.size keeps accepting items one past the intended cap. Add Number.isInteger(count) to the guard:

Suggested change
{ waveLabel, count },
): Promise<WaveExportStats> => {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error(
if (!Number.isFinite(count) || !Number.isInteger(count) || count <= 0) {

Comment on lines +311 to +318
);
for (const row of page.page) {
const email = row.normalizedEmail;
if (!email || email.length === 0) continue;
if (suppressedSet.has(email)) continue;
if (paidSet.has(email)) continue;
if (row.proLaunchWave) continue;
reservoir.offer(email);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Sequential stamp loop is N round-trips for N contacts

The stamp loop issues one ctx.runMutation per picked email, so a wave of 500 contacts makes 500 serialized Convex round-trips before the Resend push even begins. A batched mutation accepting a list of {normalizedEmail, waveLabel, assignedAt} tuples would reduce this to a single round-trip and also make the stamp phase closer to atomic.

Comment on lines +146 to +180
.map((row) => row.normalizedEmail)
.filter((e): e is string => typeof e === "string" && e.length > 0);
},
});

/**
* Snapshot of paid (customer) normalizedEmails. Mirrors
* `audienceExport.getPaidEmails` including the `email`-fallback
* defence-in-depth for un-backfilled rows.
*/
export const _getPaidEmails = internalQuery({
args: {},
handler: async (ctx) => {
const all = await ctx.db.query("customers").collect();
return all
.map((row) => {
const stored = row.normalizedEmail;
if (stored && stored.length > 0) return stored;
return (row.email ?? "").trim().toLowerCase();
})
.filter((e): e is string => typeof e === "string" && e.length > 0);
},
});

/**
* Paginated page of registrations. Mirrors
* `audienceExport.getRegistrationsPage` shape so the call shape is
* identical between exporters.
*/
export const _getRegistrationsPage = internalQuery({
args: {
cursor: v.union(v.string(), v.null()),
numItems: v.number(),
},
handler: async (ctx, { cursor, numItems }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _getSuppressedEmails, _getPaidEmails, and _getRegistrationsPage duplicated from audienceExport.ts

These three internal queries are acknowledged as mirrors of the originals, but the PR also extracted Resend helpers into _resendContacts.ts specifically to avoid exactly this kind of duplication. Keeping three separate copies of the same DB-read logic means future dedup-rule changes must be applied in both places.

P1 from review: previous order stamped all picked contacts BEFORE
attempting Resend push. If `createSegment` threw, or any
`upsertContactToSegment` returned `failed`, those contacts were
permanently excluded from future waves but never landed in a
sendable Resend segment — silently stranded.

New order:
  1. Pick N (in-memory reservoir, no side effects)
  2. createSegment — atomic, throws on failure → no contacts stamped
  3. For each picked: push first, stamp ONLY on success
     (created / linkedExisting / alreadyInSegment).
     Failed pushes leave the contact unstamped → available for
     next wave's pick.

Edge case (rare): push succeeds, stamp throws. Contact is in the
Resend segment but unstamped → may be re-picked into a later wave
and receive a duplicate email. Counted in new `stampFailed` stat;
operator can manually stamp via Data Explorer if it happens. We
don't roll back the Resend push (the DELETE call is a worse risk
than the duplicate-email exposure).

The new `stampFailed: number` field in WaveExportStats surfaces
this case explicitly. Documented in the file docstring's
"Atomicity" section so the next reader doesn't try to "simplify"
back to the unsafe stamp-first ordering.
@koala73
koala73 merged commit 77884ad into main Apr 27, 2026
11 checks passed
@koala73
koala73 deleted the feat/audience-wave-export branch April 27, 2026 14:26
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…esh Resend segment (koala73#3462)

* feat(broadcast): per-wave audience export — pick N, stamp, push to fresh Resend segment

The sustainable per-send primitive for the PRO-launch ramp. Replaces
manual dashboard sub-segmenting with one CLI command per wave; the
existing canary-250 stamps already in registrations naturally exclude
yesterday's recipients from being picked again.

  npx convex run broadcast/audienceWaveExport:assignAndExportWave \
    '{"waveLabel":"wave-2","count":500}'
  # → returns { segmentId, assigned, ... }

  # Then existing flow:
  npx convex run broadcast/sendBroadcast:createProLaunchBroadcast \
    '{"segmentId":"<returned>","nameSuffix":"wave-2"}'

What it does:
1. Refuse if waveLabel already has stamped rows (operator picks unique
   label per wave; prevents accidental double-stamping).
2. Page registrations.paginate (1000/page), apply same dedup rules as
   audienceExport.ts (empty / suppressed / paid / already-in-prior-wave).
   Reservoir-sample N via Algorithm R — fair sample, single pass,
   O(N) memory.
3. Stamp each picked row with proLaunchWave + assignedAt via the
   shared _stampWaveByNormalizedEmail mutation (mirrors the
   canary-250 backfill action).
4. Create a fresh Resend segment via POST /segments named
   `pro-launch-${waveLabel}`.
5. Push picked contacts via the shared upsertContactToSegment helper
   (same two-step pattern audienceExport already uses — handles the
   "global contact exists, segments field not applied on duplicate
   422" Resend API quirk).
6. Return { segmentId, assigned, linkedExisting, alreadyExists, failed,
   underfilled }.

Companion refactor — extracted Resend helpers to a shared module:
  - `_resendContacts.ts` (NEW): RESEND_API_BASE, USER_AGENT,
    isDuplicateContactError, UpsertOutcome, upsertContactToSegment,
    and createSegment.
  - `audienceExport.ts`: replaced its inline copies with imports from
    the new module. No behaviour change; just dedup.

Why Resend can't do this natively: verified against Resend docs —
POST /broadcasts accepts segment_id only (no exclude/sample/limit
params), POST /segments accepts name only (segments are membership
lists, not query-defined via API). Progressive waves require tracking
membership somewhere; Convex is the right source of truth since dedup
math already runs there.

Convex codegen regenerated and committed (api.d.ts now includes
audienceWaveExport's internal mutation/query/action). Convex
typecheck (`tsc -p convex/tsconfig.json`) clean. Sentry-coverage lint
guard clean.

* fixup: reorder wave export to push-first, stamp-only-on-success

P1 from review: previous order stamped all picked contacts BEFORE
attempting Resend push. If `createSegment` threw, or any
`upsertContactToSegment` returned `failed`, those contacts were
permanently excluded from future waves but never landed in a
sendable Resend segment — silently stranded.

New order:
  1. Pick N (in-memory reservoir, no side effects)
  2. createSegment — atomic, throws on failure → no contacts stamped
  3. For each picked: push first, stamp ONLY on success
     (created / linkedExisting / alreadyInSegment).
     Failed pushes leave the contact unstamped → available for
     next wave's pick.

Edge case (rare): push succeeds, stamp throws. Contact is in the
Resend segment but unstamped → may be re-picked into a later wave
and receive a duplicate email. Counted in new `stampFailed` stat;
operator can manually stamp via Data Explorer if it happens. We
don't roll back the Resend push (the DELETE call is a worse risk
than the duplicate-email exposure).

The new `stampFailed: number` field in WaveExportStats surfaces
this case explicitly. Documented in the file docstring's
"Atomicity" section so the next reader doesn't try to "simplify"
back to the unsafe stamp-first ordering.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant