Summary
Broadcast creation persists the parent row before inserting recipients. If recipient insertion fails, the function throws but leaves the parent broadcast persisted with status sending and no recipients.
Environment
- Version:
0.8.0
- Commit:
b8677608015790194c14ab7c85943ce26c7f9ef0
- Reproduction: Vitest characterization with synthetic data
- Repeated executions: 3
Steps to reproduce
- Call
createBroadcast() with a valid synthetic broadcast and recipient list.
- Allow the parent broadcast insert to succeed.
- Make the recipient insert return a database error.
- Inspect the persisted parent state after the function throws.
npm test -- --run src/lib/whatsapp/broadcast-partial-repro.test.ts
No production credentials, phone numbers, customer content, or external services are used.
Expected behavior
Broadcast creation should be atomic. On recipient failure, no sending parent should remain, or the parent should move to an explicit failed state with a safe retry path.
Actual behavior
The function throws BroadcastError, but the parent remains:
Parent broadcast: created
Status: sending
Recipient rows: 0
Function result: BroadcastError
The result was identical in three consecutive focused test runs.
Technical evidence
The parent is inserted first:
|
.from('broadcasts') |
|
.insert({ |
|
account_id: accountId, |
|
user_id: auditUserId, |
|
name: name || `API broadcast (${templateName})`, |
|
template_name: templateName, |
|
template_language: templateLanguage, |
|
status: 'sending', |
|
total_recipients: deduped.length, |
|
}) |
|
.select('id') |
|
.single(); |
|
if (bErr || !broadcast) { |
|
console.error('[broadcast-core] create broadcast error:', bErr); |
|
throw new BroadcastError('internal', 'Failed to create broadcast', 500); |
|
} |
|
|
|
const { data: recipientRows, error: rErr } = await db |
Recipients are inserted in a separate operation, and failure throws without compensating for the parent:
|
.from('broadcast_recipients') |
|
.insert( |
|
deduped.map((r) => ({ |
|
broadcast_id: broadcast.id, |
|
contact_id: r.contactId, |
|
status: 'pending' as const, |
|
})) |
|
) |
|
.select('id, contact_id'); |
|
if (rErr || !recipientRows) { |
|
console.error('[broadcast-core] create recipients error:', rErr); |
|
throw new BroadcastError('internal', 'Failed to create broadcast', 500); |
|
} |
|
|
|
// Pair each inserted recipient row back to its phone/params by |
|
// contact_id — unambiguous now that duplicates are collapsed. |
|
const byContact = new Map(deduped.map((r) => [r.contactId, r])); |
|
const planned: PlannedRecipient[] = recipientRows.map((row) => { |
|
const r = byContact.get(row.contact_id as string)!; |
|
return { recipientRowId: row.id as string, phone: r.phone, params: r.params }; |
|
}); |
Impact
Transient database failures can leave campaigns that appear to be sending but have no delivery plan. Operators may retry and create additional orphaned campaigns, while operational views and counts contain misleading sending records.
Scope and limitations
This is a route-logic/unit-integration characterization with a Supabase chain mock. It demonstrates the missing compensation boundary in the code; it does not claim a live database outage or a specific production failure rate.
Suggested acceptance criteria
- Parent and recipient rows are created atomically, preferably through a transaction/RPC.
- A recipient insertion failure leaves no orphaned
sending parent.
- Retry behavior is explicit and does not create duplicate campaigns.
- Regression coverage includes parent success followed by recipient failure.
Summary
Broadcast creation persists the parent row before inserting recipients. If recipient insertion fails, the function throws but leaves the parent broadcast persisted with status
sendingand no recipients.Environment
0.8.0b8677608015790194c14ab7c85943ce26c7f9ef0Steps to reproduce
createBroadcast()with a valid synthetic broadcast and recipient list.No production credentials, phone numbers, customer content, or external services are used.
Expected behavior
Broadcast creation should be atomic. On recipient failure, no
sendingparent should remain, or the parent should move to an explicit failed state with a safe retry path.Actual behavior
The function throws
BroadcastError, but the parent remains:The result was identical in three consecutive focused test runs.
Technical evidence
The parent is inserted first:
wacrm/src/lib/whatsapp/broadcast-core.ts
Lines 197 to 214 in b867760
Recipients are inserted in a separate operation, and failure throws without compensating for the parent:
wacrm/src/lib/whatsapp/broadcast-core.ts
Lines 215 to 235 in b867760
Impact
Transient database failures can leave campaigns that appear to be sending but have no delivery plan. Operators may retry and create additional orphaned campaigns, while operational views and counts contain misleading
sendingrecords.Scope and limitations
This is a route-logic/unit-integration characterization with a Supabase chain mock. It demonstrates the missing compensation boundary in the code; it does not claim a live database outage or a specific production failure rate.
Suggested acceptance criteria
sendingparent.