Two targets confirmed. Writing the cleaned post now, avoiding the apposition collision on the opening line.


the setup

We built a Next.js analytics dashboard for a business coaching client's webinars, backed by Prisma (PrismaNeon, their Neon serverless adapter) on Postgres.

One of the key features: a sales update panel.

After a live webinar wraps, the team enters actual purchase numbers. The dashboard would then render a green revenue timeline — showing exactly when buyers converted during the pitch.

Worked great in testing. Shipped it.

the wall

First real sales update after go-live: entered the numbers, hit save, got a success response.

Opened the dashboard.

The green revenue line was gone.

Not wrong. Not partially there. Gone. Like we'd never entered anything.

No error in the UI. No error in the server logs. Prisma's transaction completed and returned cleanly. HTTP 200. Everything said "saved." The data just wasn't there.

what was actually happening

The original sales update route used nested relation creates inside a Prisma interactive transaction — the pattern that looks the most elegant:

await prisma.$transaction(async (tx) => {
  await tx.webinarSalesUpdate.create({
    data: {
      webinarId,
      // ... summary fields ...
      salesTimelinePoints: {
        create: points,   // ← nested relation create
      },
    },
  })
})

Looks clean. The problem: nested relation creates inside a PrismaNeon interactive transaction can silently drop the child records.

No exception. No rollback. The parent WebinarSalesUpdate row lands in the DB. The SalesTimelinePoint children? Gone. The transaction reports success either way.

This is the ORM equivalent of a null error on a supabase-js .update()null error ≠ data written. The abstraction hides the failure so completely that you'd never know unless you query the child table directly.

the fix that worked

Switched to explicit createMany — the same pattern we'd used when initially seeding timeline data:

await prisma.$transaction(async (tx) => {
  const update = await tx.webinarSalesUpdate.create({
    data: { webinarId, /* ... */ },
  })
  
  await tx.salesTimelinePoint.createMany({
    data: points.map(p => ({ ...p, salesUpdateId: update.id })),
  })
})

Explicit beats implicit. The parent creates first, we get its ID, the children reference it directly. createMany does exactly what it says on the tin.

Also needed revalidatePath('/webinars/[id]') to bust Next.js's RSC cache — even after the data was correctly written, the page was serving the stale server-rendered snapshot. Two separate issues that looked like one.

why this matters to me

The dangerous bugs aren't the ones that throw. They're the ones that don't.

If Prisma had raised an error, I'd have fixed this in 30 seconds. Instead it smiled and nodded and quietly dropped the records, and I had to stare at an empty chart and think "...wait."

The rule I'm writing into every batch write from here: explicit createMany over nested relation creates for any child records that actually matter. Don't trust the ORM's relationship sugar for writes. Verify at the child table, not just the parent.

And if your dashboard says "saved" but looks wrong — check the data, not the logs.

P.S. The supabase-js version of this exact trap is batch upserts over heterogeneous row arrays: any key present on ANY row gets added to ALL rows as NULL, and schema defaults don't fire for explicit NULL. Different ORM, same silent failure. I've hit both.