Version conflicts on the edit form
Open one of your invoices in two browser tabs. Change the customer in the first and save. Change the status in the second and save. The second save wins, silently overwriting the first. That is last-write-wins, and it is dangerous because nobody sees it happen.
In this lesson you close it. You add the version precondition to updateInvoice, so the second tab’s stale save returns a conflict instead of clobbering the row. Then you render a banner showing what the server holds now, with a Use latest button to pull those values in and resubmit, and an admin-only Overwrite anyway button to force the write.
Your mission
Section titled “Your mission”The fix rests on one discipline: every write checks three preconditions before it touches the row. Tenancy: is this row in the acting org? Lifecycle: is it still editable, or soft-deleted? Version: is it still the row the client last saw? Miss tenancy and one tenant overwrites another’s data; miss lifecycle and an edit revives a deleted row; miss version and you get the silent clobber from the intro.
Two of the three are already wired in the starter. The row loads through findInvoice(ctx.orgId, id), so a row in another org is simply not found, and a soft-deleted row (deletedAt !== null) takes the same not-found path: the lookup enforces both tenancy and lifecycle. Only the version check is missing, and it must run against the row you just loaded, in the same request. Split the read and the write across two round trips and you reopen the race, because the row can change in the gap. Read and compare are one step; a mismatch is the conflict.
The conflict result carries that row inline as current, so the client never refetches: the banner rebuilds from the payload and Use latest reseeds the form from it.
Overwrite anyway deliberately skips the version check, so you show it to admins and hide it from members. But hiding a button is cosmetic: a member can forge overwrite=true into the form data, so the admin gate must be re-checked inside the action with roleAtLeast(ctx.role, 'admin'). Forging the hidden version instead just earns another conflict, caught by the server-side Zod parse.
On a legacy table you could reuse updatedAt and skip the migration, but two writes in the same clock tick can collide, so this project carries a dedicated version column that every write increments.
The lifecycle actions you built earlier also return conflicts on a stale version, but they surface a toast rather than this banner, since a table row has no form state to merge the server’s values into.
Coding time
Section titled “Coding time”Add the version precondition to updateInvoice, build the conflict branch in the edit form, and fill in the banner, against the brief and the tests. Try it before opening the solution.
Reference solution and walkthrough
1. The version precondition — src/lib/invoices/actions.ts
Section titled “1. The version precondition — src/lib/invoices/actions.ts”The fix is three guards between loading the row and mutating it. The starter applied the edit the moment it found the row; the solution holds off until tenancy, lifecycle, the overwrite gate, and the version all clear.
const updateInvoiceSchema = z.strictObject({ id: z.string(), customerName: z.string().min(1), status: z.enum(STATUS_VALUES), total: z.string().min(1), version: z.coerce.number().int(),});
export const updateInvoice = authedAction( 'member', updateInvoiceSchema, async (input, ctx): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row || row.deletedAt !== null) { return err('not_found', 'Invoice not found.'); }
row.customerName = input.customerName; row.status = input.status; row.total = input.total; row.version += 1;
pushAudit({ orgId: ctx.orgId, actorUserId: ctx.userId, action: 'invoice.update', subjectId: row.id, });
revalidatePath('/invoices'); return ok(row); },);Silent last-write-wins. The hidden version rides in with the form, but nothing reads it: the edit applies the instant the row is found. Two stale tabs both succeed, and the later one buries the earlier.
const updateInvoiceSchema = z.strictObject({ id: z.string(), customerName: z.string().min(1), status: z.enum(STATUS_VALUES), total: z.string().min(1), version: z.coerce.number().int(), overwrite: z.coerce.boolean().default(false),});
export const updateInvoice = authedAction( 'member', updateInvoiceSchema, async (input, ctx): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row || row.deletedAt !== null) { return err('not_found', 'Invoice not found.'); }
if (input.overwrite && !roleAtLeast(ctx.role, 'admin')) { return err('forbidden', 'Only an admin can overwrite a conflict.'); }
if (!input.overwrite && row.version !== input.version) { return conflict(CONFLICT_MESSAGE, row); }
row.customerName = input.customerName; row.status = input.status; row.total = input.total; row.version += 1; // …audit + revalidate + ok(row) as before },);Three guards before the mutation. Not-found covers tenancy and lifecycle; the admin gate refuses a forged overwrite; the version check returns a conflict carrying current. The row is touched only once all clear.
The full action body as it reads in the repo, with the schema fields and the unchanged audit-and-return tail folded — expand them for the whole thing:
const CONFLICT_MESSAGE = 'This invoice changed elsewhere — refresh to retry.';
// The `version` precondition is the optimistic-concurrency guard. FormData is// strings, so coerce; `overwrite` is the admin-only escape hatch (defaults off).const updateInvoiceSchema = z.strictObject({6 collapsed lines
id: z.string(), customerName: z.string().min(1), status: z.enum(STATUS_VALUES), total: z.string().min(1), version: z.coerce.number().int(), overwrite: z.coerce.boolean().default(false),});
export const updateInvoice = authedAction( 'member', updateInvoiceSchema, async (input, ctx): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row || row.deletedAt !== null) { return err('not_found', 'Invoice not found.'); }
// Overwrite skips the version precondition, so it is admin-only — the RBAC // gate lives HERE, not only behind the hidden UI control. A member who // forges `overwrite=true` is refused at the action. if (input.overwrite && !roleAtLeast(ctx.role, 'admin')) { return err('forbidden', 'Only an admin can overwrite a conflict.'); }
// The UPDATE applies only when the row the client last saw still matches // (tenancy + `deletedAt IS NULL` already hold above). A stale tab that lost // the race gets an honest 409 carrying the row the server holds now — one // round trip, no client refetch — never a silent clobber. if (!input.overwrite && row.version !== input.version) { return conflict(CONFLICT_MESSAGE, row); }
row.customerName = input.customerName; row.status = input.status; row.total = input.total; row.version += 1;
pushAudit({8 collapsed lines
orgId: ctx.orgId, actorUserId: ctx.userId, action: 'invoice.update', subjectId: row.id, });
revalidatePath('/invoices'); return ok(row); },);The decisions worth naming:
- The schema gains
overwrite: z.coerce.boolean().default(false). Both it andversionarrive as form strings, soz.coerceturns"false"and"42"back into a boolean and a number at the boundary, as Zod does for any FormData field. - The not-found guard is the tenancy + lifecycle pair.
findInvoice(ctx.orgId, id)scopes the lookup to the acting org, so another tenant’s ID misses (!row), androw.deletedAt !== nullrejects a soft-deleted row. Both collapse into one “not found”. - The admin gate sits before the version check. Check the role first and a member forging
overwrite=truehitsforbiddenregardless of their version, instead of slipping through when it happens to match. - The version check runs against the freshly loaded
rowin this same call, so there is no read-to-write window for it to drift. A mismatch returnsconflict(CONFLICT_MESSAGE, row), packing that current row into the result’scurrentfield so the losing tab recovers in one round trip, no refetch.
2. The conflict branch — src/app/(app)/invoices/[id]/edit/edit-form.tsx
Section titled “2. The conflict branch — src/app/(app)/invoices/[id]/edit/edit-form.tsx”The starter form already does the hard parts: it drives updateInvoice through useActionState, keeps the inputs uncontrolled with defaultValue, keys the field block on ${seed.id}:${seed.version} to remount with fresh defaults, and routes submits through a thin onSubmit wrapper. Five additions make it conflict-aware.
const [state, action] = useActionState(updateInvoice, null);const formRef = useRef<HTMLFormElement>(null);
const [seed, setSeed] = useState(invoice);const [conflictRow, setConflictRow] = useState<Invoice | null>(null);
useEffect(() => { if (!state) { return; } if (state.ok) { setSeed(state.data); setConflictRow(null); return; } setConflictRow( state.error.code === 'conflict' ? (state.error.current as Invoice) : null, );}, [state]);
const onUseLatest = () => { if (conflictRow) { setSeed(conflictRow); setConflictRow(null); }};
const onOverwrite = () => { const form = formRef.current; if (!form) { return; } const formData = new FormData(form); formData.set('overwrite', 'true'); action(formData);};A formRef on the <form>. Overwrite anyway reads the user’s current field values straight from the DOM, so the form needs a ref. (ref={formRef} lands on the <form> below.)
const [state, action] = useActionState(updateInvoice, null);const formRef = useRef<HTMLFormElement>(null);
const [seed, setSeed] = useState(invoice);const [conflictRow, setConflictRow] = useState<Invoice | null>(null);
useEffect(() => { if (!state) { return; } if (state.ok) { setSeed(state.data); setConflictRow(null); return; } setConflictRow( state.error.code === 'conflict' ? (state.error.current as Invoice) : null, );}, [state]);
const onUseLatest = () => { if (conflictRow) { setSeed(conflictRow); setConflictRow(null); }};
const onOverwrite = () => { const form = formRef.current; if (!form) { return; } const formData = new FormData(form); formData.set('overwrite', 'true'); action(formData);};A conflictRow state slot. When it holds an invoice the banner renders; when null the form is clean. This one switch controls the whole conflict surface.
const [state, action] = useActionState(updateInvoice, null);const formRef = useRef<HTMLFormElement>(null);
const [seed, setSeed] = useState(invoice);const [conflictRow, setConflictRow] = useState<Invoice | null>(null);
useEffect(() => { if (!state) { return; } if (state.ok) { setSeed(state.data); setConflictRow(null); return; } setConflictRow( state.error.code === 'conflict' ? (state.error.current as Invoice) : null, );}, [state]);
const onUseLatest = () => { if (conflictRow) { setSeed(conflictRow); setConflictRow(null); }};
const onOverwrite = () => { const form = formRef.current; if (!form) { return; } const formData = new FormData(form); formData.set('overwrite', 'true'); action(formData);};The result handler, extended. On ok it reseeds the form to the returned row and clears conflictRow, so the next save starts fresh and cannot self-conflict. On a conflict code it pulls state.error.current into conflictRow; any other error leaves the banner closed.
const [state, action] = useActionState(updateInvoice, null);const formRef = useRef<HTMLFormElement>(null);
const [seed, setSeed] = useState(invoice);const [conflictRow, setConflictRow] = useState<Invoice | null>(null);
useEffect(() => { if (!state) { return; } if (state.ok) { setSeed(state.data); setConflictRow(null); return; } setConflictRow( state.error.code === 'conflict' ? (state.error.current as Invoice) : null, );}, [state]);
const onUseLatest = () => { if (conflictRow) { setSeed(conflictRow); setConflictRow(null); }};
const onOverwrite = () => { const form = formRef.current; if (!form) { return; } const formData = new FormData(form); formData.set('overwrite', 'true'); action(formData);};Use latest. Swap seed to the server’s conflictRow and clear the banner. The field block is keyed on ${seed.id}:${seed.version}, so swapping the seed remounts it and resets the hidden version input to current.version. The next submit carries the matching version and succeeds.
const [state, action] = useActionState(updateInvoice, null);const formRef = useRef<HTMLFormElement>(null);
const [seed, setSeed] = useState(invoice);const [conflictRow, setConflictRow] = useState<Invoice | null>(null);
useEffect(() => { if (!state) { return; } if (state.ok) { setSeed(state.data); setConflictRow(null); return; } setConflictRow( state.error.code === 'conflict' ? (state.error.current as Invoice) : null, );}, [state]);
const onUseLatest = () => { if (conflictRow) { setSeed(conflictRow); setConflictRow(null); }};
const onOverwrite = () => { const form = formRef.current; if (!form) { return; } const formData = new FormData(form); formData.set('overwrite', 'true'); action(formData);};Overwrite anyway. Read the user’s edits out of the live form with new FormData(form), set overwrite to 'true', and dispatch. This sends the user’s values, not the server’s, with the bypass flag, so the action applies them despite the stale version. The server re-checks the admin gate.
The banner renders below the Save button, passing the admin check down so the destructive button appears only for those allowed to use it:
{conflictRow ? ( <ConflictBanner current={conflictRow} onUseLatest={onUseLatest} onOverwrite={onOverwrite} canOverwrite={roleAtLeast(role, 'admin')} />) : null}3. The banner — src/app/(app)/invoices/[id]/edit/conflict-banner.tsx
Section titled “3. The banner — src/app/(app)/invoices/[id]/edit/conflict-banner.tsx”A presentational component. It shows the server’s current customer, status, and total so the user can compare before deciding, then offers Use latest to everyone and Overwrite anyway only when canOverwrite is true.
'use client';
import { Button } from '@/components/ui/button';import type { Invoice } from '@/server/types';
// The honest-409 surface: the server returned the row it holds now as `current`,// so the stale tab can recover without a refetch. "Use latest" pulls those// values into the form (and resets the hidden version) so the resubmit succeeds.// "Overwrite anyway" renders ONLY for an admin — the gate is enforced again at// the action, this affordance is the cosmetic half of that gate.export const ConflictBanner = ({ current, onUseLatest, onOverwrite, canOverwrite,}: { current: Invoice; onUseLatest: () => void; onOverwrite: () => void; canOverwrite: boolean;}) => ( <div data-testid="conflict-banner" className="space-y-3 rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-sm" > <p className="font-medium text-destructive"> This invoice changed elsewhere while you were editing. </p> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-muted-foreground"> <dt>Customer</dt> <dd className="text-foreground">{current.customerName}</dd> <dt>Status</dt> <dd className="text-foreground capitalize">{current.status}</dd> <dt>Total</dt> <dd data-testid="conflict-current-total" className="text-foreground"> {current.currency} {current.total} </dd> </dl> <div className="flex flex-wrap gap-2"> <Button type="button" size="sm" variant="outline" data-testid="conflict-use-latest" onClick={onUseLatest} > Use latest </Button> {canOverwrite ? ( <Button type="button" size="sm" variant="destructive" data-testid="conflict-overwrite" onClick={onOverwrite} > Overwrite anyway </Button> ) : null} </div> </div>);Two details: the capitalize class renders the stored 'paid' as “Paid” without changing the data, and the data-testid hooks give the test suite a stable handle without affecting behavior.
The lifecycle conflict path is already done
Section titled “The lifecycle conflict path is already done”You write no new code here, but it is worth seeing how the same conflict result lands differently on the table. The archive, restore, and soft-delete actions you built earlier already return conflict(message, row) on a stale version. There it surfaces as a toast, not the banner, because a table row has no form state to merge the server’s values into. And when an optimistic archive loses the race, the row reappears: the optimistic value was never committed, so it expires the moment the transition ends on a returned { ok: false }. That is expiry, not a rollback.
The version-token-prevents-lost-updates pattern this action implements, spelled out as the canonical OCC design.
Reference for the hook the conflict branch reads state from — including the form-action and pending shapes.
How z.coerce turns the FormData strings for version and overwrite back into a number and a boolean at the boundary.
The HTTP semantics behind the conflict result you return when the stale version loses the race.
Moment of truth
Section titled “Moment of truth”Run the suite for this lesson:
pnpm test:lesson 5It should pass: the suite exercises the precondition and the action gate end to end — clean saves, stale-version conflicts, the admin overwrite, a member’s refused overwrite, and the cross-tenant probe.
The visual recovery loop and the toast paths you confirm by hand, using the inspector page:
org-acme:member via the inspector and trigger a conflict on the edit form: the banner renders, but the Overwrite anyway button is absent — members only see Use latest.org-globex:admin, hand-construct an edit URL for an org-acme invoice ID: the detail page is not found because the read is org-scoped, and a forged submit of that ID takes the not-found path at the write.all tab, archive and restore, the two-tab conflict — and confirm every behavior holds end to end.That last item is the finish line: every chapter behavior holding at once.