Skip to content

chore(broadcast): backfill proLaunchWave stamps for canary-250 contacts#3453

Merged
koala73 merged 3 commits into
mainfrom
chore/canary-250-wave-stamp-backfill
Apr 27, 2026
Merged

chore(broadcast): backfill proLaunchWave stamps for canary-250 contacts#3453
koala73 merged 3 commits into
mainfrom
chore/canary-250-wave-stamp-backfill

Conversation

@koala73

@koala73 koala73 commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

The 244 registrations who received yesterday's PRO-launch canary broadcast need a wave stamp in Convex so future wave-export actions can exclude them. Without this stamp, the next wave-export would re-pick them and re-email — exactly the duplication the wave system exists to prevent.

This PR lays the schema + the one-shot backfill. The full wave-export action (pick N unstamped, stamp them, push to a fresh Resend segment per wave) comes in a follow-up PR.

Why this design

Resend's API doesn't support broadcast subset/sample/exclude (verified via docs):

  • POST /broadcasts accepts segment_id only — no inclusion/exclusion filters.
  • POST /segments accepts name only — segments are membership lists, not query-defined.
  • Contacts have properties (custom k/v) but no documented query API.

So progressive waves require tracking membership somewhere. Convex is the right source of truth for us — it's already where dedup math runs, and it lets us scan unstamped registrations efficiently.

Changes

convex/schema.tsregistrations table

Two new optional fields + one index:

  • proLaunchWave?: v.optional(v.string()) — wave label (e.g. "canary-250", "wave-2")
  • proLaunchWaveAssignedAt?: v.optional(v.number()) — ms timestamp, mostly for audit
  • .index("by_proLaunchWave", ["proLaunchWave"]) — so the next-wave pick can scan only-unstamped efficiently against tens of thousands of registrations

Both fields optional so existing rows pass schema validation.

convex/broadcast/backfillCanaryWaveStamps.ts (new)

Two exports:

  • _stampWaveByNormalizedEmail — internal mutation that looks up registrations by normalizedEmail, patches the wave fields if not already stamped. Idempotent on the wave label.
  • backfillCanary250 — internal action that pages Resend GET /contacts?segment_id=<canary> (cursor pagination via after=<contact-id>, 100/page max) and stamps each contact's matching registration.

Hard-coded to the canary-250 segment ID and the sent_at timestamp from the broadcast — this is a one-off historical backfill, not a generic wave-stamp tool.

PII safety: emails masked in logs (jo******@example.com shape) — Convex dashboard is observable to project viewers, raw waitlist addresses must not land in plaintext.

Test plan

  • Pre-push hooks green (typecheck, biome, sentry-coverage, edge bundle, version sync)
  • After merge + Convex deploy: npx convex run broadcast/backfillCanaryWaveStamps:backfillCanary250
    • Expect: { fetched: 244, stamped: ~244, alreadyStamped: 0, notFound: <small>, failed: 0 }
    • notFound may be non-zero if test addresses or manual additions ended up in the canary segment without a matching registrations row — log inspection (masked) to confirm those are expected.
  • Re-run the same command — expect alreadyStamped count = previous stamped count, stamped: 0. Idempotency verified.
  • Spot-check via Data Explorer: filter registrations by proLaunchWave = "canary-250" — count should match stamped + alreadyStamped.

What comes next (separate PR)

convex/broadcast/audienceWaveExport.ts:assignAndExportWave({ waveLabel, count }) that:

  1. Picks count registrations where proLaunchWave IS NULL AND not in suppressions/customers, random sample.
  2. Stamps each with the new waveLabel.
  3. Creates a fresh Resend segment via API.
  4. Pushes the picked contacts to it.
  5. Returns the new segmentId for the existing createProLaunchBroadcast flow.

That action is the sustainable per-wave primitive — one CLI command per send, no manual dashboard work, no re-emailing previous waves.

The 244 registrations who received yesterday's PRO-launch canary broadcast
need a wave stamp in Convex so future wave-export actions can exclude
them. Without this, the next wave-export would re-pick them and re-email.

Two pieces:

1. Schema: add `proLaunchWave?: v.string()` and
   `proLaunchWaveAssignedAt?: v.number()` to `registrations`, plus a
   `by_proLaunchWave` index for efficient unstamped-only scans at next
   wave's pick time. Both fields optional so existing rows pass schema
   validation.

2. One-shot internal action `backfillCanaryWaveStamps:backfillCanary250`:
   - Pages Resend `GET /contacts?segment_id=<canary>` (cursor-based via
     `after=<contact-id>`, max 100/page)
   - Normalizes each email (`trim().toLowerCase()` — same convention
     `registrations.normalizedEmail` uses)
   - Calls internal mutation `_stampWaveByNormalizedEmail` to look up
     and patch the matching registration
   - Reports {fetched, stamped, alreadyStamped, notFound, failed}
   - Idempotent — re-runs are no-ops on already-stamped rows
   - Masks emails in logs (Convex dashboard is observable to project
     viewers; raw waitlist addresses must never land in plaintext logs)

The full wave-export action that handles "pick N unstamped, stamp them,
push to fresh Resend segment" comes in the next PR — this PR just lays
the schema + the canary backfill so we don't accidentally re-email the
244 when the next wave runs.

Run after deploy:
  npx convex run broadcast/backfillCanaryWaveStamps:backfillCanary250
@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 9:54am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds two optional schema fields (proLaunchWave, proLaunchWaveAssignedAt) and a supporting index to registrations, then introduces a one-shot Convex action that pages Resend's contact list for the canary-250 segment and stamps the 244 matching registrations to prevent re-inclusion in future wave exports. The backfill is well-structured and idempotent by design.

Confidence Score: 4/5

Safe to merge — no blocking issues; two P2 findings are worth addressing before this mutation pattern is reused in the follow-up wave-export PR.

Only P2 findings present. The backfill is idempotent and scoped to internal usage. The silent-overwrite concern is important context for the follow-up PR but doesn't affect the current one-shot operation since no registrations are pre-stamped.

convex/broadcast/backfillCanaryWaveStamps.ts — specifically the _stampWaveByNormalizedEmail mutation's overwrite semantics before it's promoted to a general-purpose wave primitive.

Important Files Changed

Filename Overview
convex/broadcast/backfillCanaryWaveStamps.ts New one-shot backfill action: pages Resend contacts via cursor pagination, stamps matching registrations. Logic is sound and idempotent; two P2 issues — silent overwrite of a differing existing wave label, and misclassified blank-email contacts as failed.
convex/schema.ts Adds two optional fields (proLaunchWave, proLaunchWaveAssignedAt) and a by_proLaunchWave index to registrations. All additions are optional so existing rows pass validation. Clean and correct.

Sequence Diagram

sequenceDiagram
    participant Operator
    participant backfillCanary250 as backfillCanary250 (Action)
    participant Resend as Resend API
    participant stampMutation as _stampWaveByNormalizedEmail (Mutation)
    participant DB as Convex DB (registrations)

    Operator->>backfillCanary250: npx convex run ...
    backfillCanary250->>Resend: GET /contacts?segment_id=canary-250&limit=100
    Resend-->>backfillCanary250: { data: [...], has_more: true }
    loop For each contact in page
        backfillCanary250->>stampMutation: runMutation(normalizedEmail, waveLabel, assignedAt)
        stampMutation->>DB: query by_normalized_email
        DB-->>stampMutation: row (or null)
        alt row not found
            stampMutation-->>backfillCanary250: { result: notFound }
        else already stamped with same label
            stampMutation-->>backfillCanary250: { result: alreadyStamped }
        else
            stampMutation->>DB: patch proLaunchWave + proLaunchWaveAssignedAt
            stampMutation-->>backfillCanary250: { result: stamped }
        end
    end
    backfillCanary250->>Resend: GET /contacts?segment_id=canary-250&after=last_id
    Resend-->>backfillCanary250: { data: [...], has_more: false }
    Note over backfillCanary250: has_more=false, break
    backfillCanary250-->>Operator: BackfillStats { fetched, stamped, alreadyStamped, notFound, failed }
Loading

Reviews (1): Last reviewed commit: "chore(broadcast): backfill proLaunchWave..." | Re-trigger Greptile

Comment on lines +80 to +88
if (row.proLaunchWave === waveLabel) {
return { result: "alreadyStamped" as const };
}
await ctx.db.patch(row._id, {
proLaunchWave: waveLabel,
proLaunchWaveAssignedAt: assignedAt,
});
return { result: "stamped" as const };
},

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 Silent overwrite of a different existing wave label

_stampWaveByNormalizedEmail only short-circuits when row.proLaunchWave === waveLabel; if the row already carries a different wave label it silently overwrites it and returns "stamped", indistinguishable from a fresh stamp. For the current one-off canary backfill this is harmless (no rows are pre-stamped), but the future wave-export action described in the PR will reuse this mutation. A row that was accidentally stamped with "wave-2" would silently get clobbered to "canary-250" on a re-run, with no indication in stats. Adding an "alreadyStampedDifferentWave" result (or a guard that throws) would make the contract explicit before this mutation is promoted to the generic wave primitive.

Comment on lines +179 to +183
const normalizedEmail = (contact.email ?? "").trim().toLowerCase();
if (!normalizedEmail) {
stats.failed++;
continue;
}

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 Empty-email contacts silently counted as failed

Contacts with a missing or blank email field (e.g. a manually-added Resend entry with no address) hit stats.failed++ and continue. failed is semantically "stamp operation threw an error", so using it here inflates the error count with a structurally-invalid contact. A dedicated skipped counter (or a log line before continue) would make the post-run stats unambiguous and easier to reconcile against the expected 244.

P1: wrong Resend endpoint
The action built `GET /contacts?segment_id=...`. That URL exists but the
canonical per-segment listing endpoint is
`GET /segments/{segment_id}/contacts` (verified against Resend docs:
https://resend.com/docs/api-reference/segments/list-segment-contacts).
The wrong URL would have failed before stamping any rows, leaving the
244 canary contacts eligible for re-emailing in the next wave —
defeating the entire point of the backfill.

P1: lint guard flagged the per-contact catch
The `try/catch + console.error + stats.failed++` block in
`backfillCanary250` is intentional — per-contact stamp failures are
counted into `stats.failed` and surfaced in the action's return value
(the operator's visible surface for partial failures). Re-throwing
would abort the whole loop on the first failure and leave most
contacts unstamped. Convex auto-Sentry still captures the underlying
mutation throw inside the mutation itself, before it bubbles here as a
rejection.

Added `// sentry-coverage-ok:` marker INSIDE the catch body (the lint
guard checks the body, not surrounding lines) with a multi-line
rationale so the next reader doesn't undo the choice. Lint guard now
clean: 153 files --all, 2 files --diff.
P1: backfill stamp not used by current export path
The schema doc claimed "future wave exports filter on
proLaunchWave === undefined", but the EXISTING audienceExport.ts (the
only exporter that exists today) skipped only on
empty/suppressed/paid — meaning a re-run against pro-launch-main
would re-pick the canary 244 (and any future stamped wave) and the
next broadcast would dupe-email them.

Extended the existing exporter:
- Added `alreadyInPriorWaveSkipped: number` to ExportStats.
- Added a per-row check: `if (row.proLaunchWave) { stats.alreadyInPriorWaveSkipped++; continue; }`.
  Sits AFTER suppressed/paid so the priority order is consistent
  (auth/permanent suppressions first, then prior-wave history).
- Both dry-run and live-mode honor the skip — operators see the
  count in the dry-run output before committing.

This makes the backfill load-bearing as advertised.

P1: stale convex codegen
Adding convex/broadcast/backfillCanaryWaveStamps.ts requires
regenerating convex/_generated/api.d.ts so the new module's
internal mutations/actions are reachable via internal.broadcast.*.
The pre-push gate runs the root + api typechecks but NOT
`tsc -p convex/tsconfig.json`, so the missing codegen slipped through.
Ran `npx convex codegen --typecheck=disable`; verified fix with
`npx tsc --noEmit -p convex/tsconfig.json` (silent / clean).
@koala73
koala73 merged commit a09cb97 into main Apr 27, 2026
10 checks passed
@koala73
koala73 deleted the chore/canary-250-wave-stamp-backfill branch April 27, 2026 10:11
koala73 added a commit that referenced this pull request Apr 27, 2026
…#3456)

* chore(pre-push): typecheck convex/ to catch stale _generated/api.d.ts

PR #3453 review caught a missing-codegen slip: a new module under
convex/broadcast/ was committed without re-running `npx convex codegen`,
so convex/_generated/api.d.ts was stale. The pre-push gate ran
`typecheck` (root) and `typecheck:api` but not
`tsc -p convex/tsconfig.json`, so the stale-codegen import error
("Property 'backfillCanaryWaveStamps' does not exist on type
'internal.broadcast'") only surfaced in PR review.

Adds `npx tsc --noEmit -p convex/tsconfig.json || exit 1` between the
existing API typecheck and the CJS syntax check. Catches:
  - stale _generated/api.d.ts (forgotten codegen after adding a module)
  - drift between convex/schema.ts and code that reads it
  - any TS error inside convex/ that the root tsconfig's project
    references would otherwise miss

* fixup: also add convex typecheck to CI typecheck.yml workflow

PR #3456 review caught the gap: pre-push runs locally and can be
bypassed (`git push --no-verify`, direct pushes to main, CI-only
paths), so the convex typecheck addition was incomplete as a
correctness gate. CI's typecheck.yml ran only `typecheck` (root) and
`typecheck:api`, leaving stale `convex/_generated/api.d.ts` slippable
through CI without a failure.

Mirrors the pre-push step into the workflow:

  - run: npx tsc --noEmit -p convex/tsconfig.json

Same step, same exit semantics. Now both layers (local pre-push +
remote CI) catch stale codegen and any drift between
`convex/schema.ts` and code that reads it.
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…ts (koala73#3453)

* chore(broadcast): backfill proLaunchWave stamps for canary-250 contacts

The 244 registrations who received yesterday's PRO-launch canary broadcast
need a wave stamp in Convex so future wave-export actions can exclude
them. Without this, the next wave-export would re-pick them and re-email.

Two pieces:

1. Schema: add `proLaunchWave?: v.string()` and
   `proLaunchWaveAssignedAt?: v.number()` to `registrations`, plus a
   `by_proLaunchWave` index for efficient unstamped-only scans at next
   wave's pick time. Both fields optional so existing rows pass schema
   validation.

2. One-shot internal action `backfillCanaryWaveStamps:backfillCanary250`:
   - Pages Resend `GET /contacts?segment_id=<canary>` (cursor-based via
     `after=<contact-id>`, max 100/page)
   - Normalizes each email (`trim().toLowerCase()` — same convention
     `registrations.normalizedEmail` uses)
   - Calls internal mutation `_stampWaveByNormalizedEmail` to look up
     and patch the matching registration
   - Reports {fetched, stamped, alreadyStamped, notFound, failed}
   - Idempotent — re-runs are no-ops on already-stamped rows
   - Masks emails in logs (Convex dashboard is observable to project
     viewers; raw waitlist addresses must never land in plaintext logs)

The full wave-export action that handles "pick N unstamped, stamp them,
push to fresh Resend segment" comes in the next PR — this PR just lays
the schema + the canary backfill so we don't accidentally re-email the
244 when the next wave runs.

Run after deploy:
  npx convex run broadcast/backfillCanaryWaveStamps:backfillCanary250

* fixup: address review on PR koala73#3453 — fix Resend URL + lint guard

P1: wrong Resend endpoint
The action built `GET /contacts?segment_id=...`. That URL exists but the
canonical per-segment listing endpoint is
`GET /segments/{segment_id}/contacts` (verified against Resend docs:
https://resend.com/docs/api-reference/segments/list-segment-contacts).
The wrong URL would have failed before stamping any rows, leaving the
244 canary contacts eligible for re-emailing in the next wave —
defeating the entire point of the backfill.

P1: lint guard flagged the per-contact catch
The `try/catch + console.error + stats.failed++` block in
`backfillCanary250` is intentional — per-contact stamp failures are
counted into `stats.failed` and surfaced in the action's return value
(the operator's visible surface for partial failures). Re-throwing
would abort the whole loop on the first failure and leave most
contacts unstamped. Convex auto-Sentry still captures the underlying
mutation throw inside the mutation itself, before it bubbles here as a
rejection.

Added `// sentry-coverage-ok:` marker INSIDE the catch body (the lint
guard checks the body, not surrounding lines) with a multi-line
rationale so the next reader doesn't undo the choice. Lint guard now
clean: 153 files --all, 2 files --diff.

* fixup: address review on PR koala73#3453 — close the wave-skip + regen api.d.ts

P1: backfill stamp not used by current export path
The schema doc claimed "future wave exports filter on
proLaunchWave === undefined", but the EXISTING audienceExport.ts (the
only exporter that exists today) skipped only on
empty/suppressed/paid — meaning a re-run against pro-launch-main
would re-pick the canary 244 (and any future stamped wave) and the
next broadcast would dupe-email them.

Extended the existing exporter:
- Added `alreadyInPriorWaveSkipped: number` to ExportStats.
- Added a per-row check: `if (row.proLaunchWave) { stats.alreadyInPriorWaveSkipped++; continue; }`.
  Sits AFTER suppressed/paid so the priority order is consistent
  (auth/permanent suppressions first, then prior-wave history).
- Both dry-run and live-mode honor the skip — operators see the
  count in the dry-run output before committing.

This makes the backfill load-bearing as advertised.

P1: stale convex codegen
Adding convex/broadcast/backfillCanaryWaveStamps.ts requires
regenerating convex/_generated/api.d.ts so the new module's
internal mutations/actions are reachable via internal.broadcast.*.
The pre-push gate runs the root + api typechecks but NOT
`tsc -p convex/tsconfig.json`, so the missing codegen slipped through.
Ran `npx convex codegen --typecheck=disable`; verified fix with
`npx tsc --noEmit -p convex/tsconfig.json` (silent / clean).
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…koala73#3456)

* chore(pre-push): typecheck convex/ to catch stale _generated/api.d.ts

PR koala73#3453 review caught a missing-codegen slip: a new module under
convex/broadcast/ was committed without re-running `npx convex codegen`,
so convex/_generated/api.d.ts was stale. The pre-push gate ran
`typecheck` (root) and `typecheck:api` but not
`tsc -p convex/tsconfig.json`, so the stale-codegen import error
("Property 'backfillCanaryWaveStamps' does not exist on type
'internal.broadcast'") only surfaced in PR review.

Adds `npx tsc --noEmit -p convex/tsconfig.json || exit 1` between the
existing API typecheck and the CJS syntax check. Catches:
  - stale _generated/api.d.ts (forgotten codegen after adding a module)
  - drift between convex/schema.ts and code that reads it
  - any TS error inside convex/ that the root tsconfig's project
    references would otherwise miss

* fixup: also add convex typecheck to CI typecheck.yml workflow

PR koala73#3456 review caught the gap: pre-push runs locally and can be
bypassed (`git push --no-verify`, direct pushes to main, CI-only
paths), so the convex typecheck addition was incomplete as a
correctness gate. CI's typecheck.yml ran only `typecheck` (root) and
`typecheck:api`, leaving stale `convex/_generated/api.d.ts` slippable
through CI without a failure.

Mirrors the pre-push step into the workflow:

  - run: npx tsc --noEmit -p convex/tsconfig.json

Same step, same exit semantics. Now both layers (local pre-push +
remote CI) catch stale codegen and any drift between
`convex/schema.ts` and code that reads it.
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