Skip to content
Chapter 62Lesson 5

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.

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.

Editing the same invoice in two tabs lets the first submit succeed; the second submit is refused without mutating the row, and the form renders the conflict banner showing the server’s current values.
tested
Clicking Use latest pulls the server’s current row into the form so the resubmit succeeds.
tested
Clicking Overwrite anyway resends the user’s edits and applies them despite the stale version — and a member who forges the overwrite flag is refused, the row untouched.
tested
A forged submit carrying another org’s invoice ID takes the not-found path in the acting org and never mutates that row.
tested
Overwrite anyway renders only for admins; members never see the button.
untested
A lifecycle action (archive / restore / delete) hitting a stale version surfaces a conflict toast, not the full banner.
untested
Forcing version drift and then archiving the same row shows the optimistic removal briefly before the row reappears, and a conflict toast fires.
untested

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.

src/lib/invoices/actions.ts
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.

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:

src/lib/invoices/actions.ts
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 and version arrive as form strings, so z.coerce turns "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), and row.deletedAt !== null rejects 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=true hits forbidden regardless of their version, instead of slipping through when it happens to match.
  • The version check runs against the freshly loaded row in this same call, so there is no read-to-write window for it to drift. A mismatch returns conflict(CONFLICT_MESSAGE, row), packing that current row into the result’s current field 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.

1 / 1

The banner renders below the Save button, passing the admin check down so the destructive button appears only for those allowed to use it:

src/app/(app)/invoices/[id]/edit/edit-form.tsx
{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.

src/app/(app)/invoices/[id]/edit/conflict-banner.tsx
'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.

Run the suite for this lesson:

Terminal window
pnpm test:lesson 5

It 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:

Open an invoice in two tabs (the inspector’s “Open in two tabs” link). Save tab one — confirm the version bumped via the inspector’s audit log. Edit and save tab two: the conflict banner renders the current server values. Click Use latest; the form reloads with the server’s values and new version; the resubmit succeeds.
untested
Use the inspector’s Force version drift to make a row go stale without a second tab, then resubmit an open edit form — the banner appears just the same.
untested
Switch to 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.
untested
Force version drift on a row, then archive it from the table: the optimistic removal shows briefly, the row reappears when the conflict returns, and a conflict toast fires — not the banner.
untested
As 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.
untested
Walk the chapter’s full acceptance list one more time — URL-driven view, RBAC-gated all tab, archive and restore, the two-tab conflict — and confirm every behavior holds end to end.
untested

That last item is the finish line: every chapter behavior holding at once.