Skip to content
Chapter 47Lesson 3

Edit an invoice

Build the update path of a CRUD surface: a tenant-scoped Server Action that saves in place with revalidatePath, no redirect.

The create form writes new rows; this lesson teaches the form that changes existing ones. By the end you can open an invoice, edit its fields, and save in place: the page refreshes with the new values — no navigation, no reload — and a number that collides with another invoice comes back as a conflict banner.

Here is the shape of it. /invoices/[invoiceId] already renders the read-only detail panel; below it sits an edit form, prefilled with the invoice’s current values. Change the total, pick a new status, fix a date, hit save. The action writes the row and, instead of redirecting, calls revalidatePath so the Server Component above re-fetches and the form re-renders with what you just saved. Reuse a number another invoice in the same org already has, and the save bounces back with a banner across the top of the form — not a message under the number field, because the database draws the uniqueness line across the whole org.

Most of this you built in Create an invoice: the action shape, the uncontrolled-input form, the <SubmitButton> and <FieldError>, the reset-on-commit trick. Only two things change — a schema that demands the row’s id, and a write that refuses to reach across organization boundaries.

Editing is the create path with two new ideas, so most of the work is spotting what carries over.

The first is the schema. An update needs to know which row it changes, so the input schema extends the create schema with a required idcreateInvoiceInputSchema.extend({ id: z.uuid() }) — and the form posts that id as a hidden input. Every rule the create schema enforces comes along for free; you add one field, you don’t rewrite a validator.

The second idea is the reflex worth keeping for the rest of your career: the tenant guard belongs in the where clause. Scope the update with and(eq(invoices.id, parsed.data.id), eq(invoices.organizationId, organizationId)), and a forged id from another org matches zero rows. The trap is to load the row, check its organizationId in application code, then update — the IDOR-class hole from The single-round-trip invoice detail read, where a window opens between read and write and one forgotten check leaks a foreign row. Put the constraint in the query and the database enforces it atomically: no window, nothing to forget.

Two consequences follow. Unlike create, updateInvoice returns ok without redirecting — the user is already on the form they want to keep editing. Because the action calls revalidatePath('/invoices'), the Server Component that loads the invoice re-runs and the fresh values flow back into the form’s defaults, with no client state to synchronize. A duplicate number surfaces as a form-level banner, not a field error, because the unique constraint is on the (organizationId, number) composite: the conflict is a property of the pair, so there is no single field to pin the message to.

The form mirrors NewInvoiceForm; the one real difference is that every defaultValue is seeded from the loaded invoice the page passes as a prop, with dates formatted to yyyy-mm-dd for the native date input. Keep the inputs uncontrolled so revalidatePath can flow new defaults in without hand-synced state. Out of scope: optimistic UI (Optimistic create) and the Drizzle transaction (the delete lesson).

Opening /invoices/[invoiceId] shows the edit form prefilled with the invoice’s current values.
tested
Saving valid changes persists them and the page reflects the new values without a manual reload.
tested
Editing one org’s invoice cannot modify another org’s row — the tenant filter is in the where.
tested
Setting an invoice’s number to one already used by another invoice in the same org surfaces a form-level banner, not a field error.
tested
An invalid edit re-renders the form with field-level messages and keeps the entered values.
untested

Implement updateInvoiceInputSchema, updateInvoice, and the body of EditInvoiceForm against the brief and the tests. Try it before opening the reference — the tenant-where reflex only sticks once you’ve reached for the wrong shape and felt why it fails.

Reference solution and walkthrough

The update schema sits beside the create schema in lib/invoices/mutation-schemas.ts as a single extension:

lib/invoices/mutation-schemas.ts
export const updateInvoiceInputSchema = createInvoiceInputSchema.extend({
id: z.uuid(),
});
export type UpdateInvoiceInput = z.input<typeof updateInvoiceInputSchema>;
export type UpdateInvoiceOutput = z.output<typeof updateInvoiceInputSchema>;

.extend inherits every rule create already enforces — the number length bounds, the money regex on total, the date coercions — and adds one required field, id, validated as a UUID. The form posts that id as a hidden input, so a save always names exactly which row it means.

updateInvoice joins createInvoice in lib/invoices/actions.ts. It is the same parse-authorize-mutate-return shape, and nearly every seam matches create. The exception is the where, which is the whole point of the lesson.

lib/invoices/actions.ts
export const updateInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = updateInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId } = await getActiveContext();
try {
await db
.update(invoices)
.set(parsed.data)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
revalidatePath('/invoices');
return ok({ id: parsed.data.id });
};

Parse. safeParse against updateInvoiceInputSchema, so a missing id now fails validation. A bad submit returns a Result, never throws.

lib/invoices/actions.ts
export const updateInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = updateInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId } = await getActiveContext();
try {
await db
.update(invoices)
.set(parsed.data)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
revalidatePath('/invoices');
return ok({ id: parsed.data.id });
};

Authorize. Read the tenant context after the parse, so a parse failure never costs an auth lookup. An update only needs organizationId; it doesn’t restamp createdBy.

lib/invoices/actions.ts
export const updateInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = updateInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId } = await getActiveContext();
try {
await db
.update(invoices)
.set(parsed.data)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
revalidatePath('/invoices');
return ok({ id: parsed.data.id });
};

The load-bearing line. The update is scoped by both the row id and the active org, so a forged id from another org matches zero rows. The tenant guard is the query itself, which closes the IDOR hole.

lib/invoices/actions.ts
export const updateInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = updateInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId } = await getActiveContext();
try {
await db
.update(invoices)
.set(parsed.data)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
revalidatePath('/invoices');
return ok({ id: parsed.data.id });
};

The conflict catch. A duplicate (organizationId, number) trips the unique constraint; isUniqueViolation maps it to a conflict Result, and anything else re-throws.

lib/invoices/actions.ts
export const updateInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = updateInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId } = await getActiveContext();
try {
await db
.update(invoices)
.set(parsed.data)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
revalidatePath('/invoices');
return ok({ id: parsed.data.id });
};

No redirect. revalidatePath re-runs the Server Component for fresh data, then the action returns ok. The user stays on the form.

1 / 1

Two decisions carry this action. The tenant id lives in the where, not a post-load check: a findFirst-then-compare-in-JavaScript guard opens a read-then-write window and leans on a check a future edit might drop, while the query-level filter has no window and Postgres enforces it on every statement. And there’s no redirect on success: an edit leaves the user where they want to be, so revalidatePath('/invoices') invalidates the cached render, the Server Component reloads the invoice, and its fresh values flow into the form’s defaults — no client sync, no manual refetch.

app/invoices/[invoiceId]/edit-invoice-form.tsx is the create form with one job changed: every field starts at the invoice’s current value instead of blank. The page passes the loaded invoice as a prop, and the form seeds its defaults from it.

The field cluster, the <FieldError> wiring, the form-level banner, the <SubmitButton>, and the key-remount mechanic that keeps your typed values after a failed submit are all identical to NewInvoiceForm from Create an invoice. Read the top of the component for what’s new:

app/invoices/[invoiceId]/edit-invoice-form.tsx
'use client';
import { useActionState, useState } from 'react';
import { FieldError } from '@/app/_components/field-error';
import { SubmitButton } from '@/app/_components/submit-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { updateInvoice } from '@/lib/invoices/actions';
import type { InvoiceDetail } from '@/lib/invoices/queries';
import { statusSchema } from '@/lib/invoices/schema';
type EditInvoiceFormProps = {
invoice: InvoiceDetail;
customers: { id: string; name: string }[];
};
const dateInputFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// The fields whose typed values are echoed back as defaultValue on a failed
// submit. A `<form action={fn}>` fires requestFormReset on commit under
// react-dom 19, so an invalid edit would otherwise revert the inputs to the
// invoice prop's values; remounting the field cluster on each submit re-applies
// these as the initial uncontrolled values, keeping what the user typed.
const echoedFields = [
'customerId',
'number',
'status',
'total',
'issuedAt',
'dueAt',
'currency',
] as const;
export const EditInvoiceForm = ({
invoice,
customers,
}: EditInvoiceFormProps) => {
const [state, formAction] = useActionState(updateInvoice, null);
const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const [defaults, setDefaults] = useState<
Record<(typeof echoedFields)[number], string>
>({
customerId: invoice.customerId,
number: invoice.number,
status: invoice.status,
total: String(invoice.total),
issuedAt: dateInputFormat.format(invoice.issuedAt),
dueAt: dateInputFormat.format(invoice.dueAt),
currency: invoice.currency,
});
const [submitCount, setSubmitCount] = useState(0);
const handleSubmit = (formData: FormData) => {
setDefaults(
Object.fromEntries(
echoedFields.map((field) => [field, String(formData.get(field) ?? '')]),
) as Record<(typeof echoedFields)[number], string>,
);
setSubmitCount((count) => count + 1);
formAction(formData);
};
return (
<section className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Edit invoice</h2>
<form
key={submitCount}
action={handleSubmit}
data-testid="edit-invoice-form"
className="flex flex-col gap-4"
>
<input type="hidden" name="id" defaultValue={invoice.id} />
...
<SubmitButton>Save changes</SubmitButton>
</form>
</section>
);
};

The date formatter. A native <input type="date"> needs yyyy-mm-dd. The invoice’s dates arrive as Date objects, so format them with the en-CA locale (which yields yyyy-mm-dd) pinned to UTC — UTC so a date stored at midnight doesn’t slip to the previous day in a western browser.

app/invoices/[invoiceId]/edit-invoice-form.tsx
'use client';
import { useActionState, useState } from 'react';
import { FieldError } from '@/app/_components/field-error';
import { SubmitButton } from '@/app/_components/submit-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { updateInvoice } from '@/lib/invoices/actions';
import type { InvoiceDetail } from '@/lib/invoices/queries';
import { statusSchema } from '@/lib/invoices/schema';
type EditInvoiceFormProps = {
invoice: InvoiceDetail;
customers: { id: string; name: string }[];
};
const dateInputFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// The fields whose typed values are echoed back as defaultValue on a failed
// submit. A `<form action={fn}>` fires requestFormReset on commit under
// react-dom 19, so an invalid edit would otherwise revert the inputs to the
// invoice prop's values; remounting the field cluster on each submit re-applies
// these as the initial uncontrolled values, keeping what the user typed.
const echoedFields = [
'customerId',
'number',
'status',
'total',
'issuedAt',
'dueAt',
'currency',
] as const;
export const EditInvoiceForm = ({
invoice,
customers,
}: EditInvoiceFormProps) => {
const [state, formAction] = useActionState(updateInvoice, null);
const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const [defaults, setDefaults] = useState<
Record<(typeof echoedFields)[number], string>
>({
customerId: invoice.customerId,
number: invoice.number,
status: invoice.status,
total: String(invoice.total),
issuedAt: dateInputFormat.format(invoice.issuedAt),
dueAt: dateInputFormat.format(invoice.dueAt),
currency: invoice.currency,
});
const [submitCount, setSubmitCount] = useState(0);
const handleSubmit = (formData: FormData) => {
setDefaults(
Object.fromEntries(
echoedFields.map((field) => [field, String(formData.get(field) ?? '')]),
) as Record<(typeof echoedFields)[number], string>,
);
setSubmitCount((count) => count + 1);
formAction(formData);
};
return (
<section className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Edit invoice</h2>
<form
key={submitCount}
action={handleSubmit}
data-testid="edit-invoice-form"
className="flex flex-col gap-4"
>
<input type="hidden" name="id" defaultValue={invoice.id} />
...
<SubmitButton>Save changes</SubmitButton>
</form>
</section>
);
};

The props. The full InvoiceDetail the page loaded plus the customer list for the dropdown. The form prefills from the prop; it does not fetch.

app/invoices/[invoiceId]/edit-invoice-form.tsx
'use client';
import { useActionState, useState } from 'react';
import { FieldError } from '@/app/_components/field-error';
import { SubmitButton } from '@/app/_components/submit-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { updateInvoice } from '@/lib/invoices/actions';
import type { InvoiceDetail } from '@/lib/invoices/queries';
import { statusSchema } from '@/lib/invoices/schema';
type EditInvoiceFormProps = {
invoice: InvoiceDetail;
customers: { id: string; name: string }[];
};
const dateInputFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// The fields whose typed values are echoed back as defaultValue on a failed
// submit. A `<form action={fn}>` fires requestFormReset on commit under
// react-dom 19, so an invalid edit would otherwise revert the inputs to the
// invoice prop's values; remounting the field cluster on each submit re-applies
// these as the initial uncontrolled values, keeping what the user typed.
const echoedFields = [
'customerId',
'number',
'status',
'total',
'issuedAt',
'dueAt',
'currency',
] as const;
export const EditInvoiceForm = ({
invoice,
customers,
}: EditInvoiceFormProps) => {
const [state, formAction] = useActionState(updateInvoice, null);
const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const [defaults, setDefaults] = useState<
Record<(typeof echoedFields)[number], string>
>({
customerId: invoice.customerId,
number: invoice.number,
status: invoice.status,
total: String(invoice.total),
issuedAt: dateInputFormat.format(invoice.issuedAt),
dueAt: dateInputFormat.format(invoice.dueAt),
currency: invoice.currency,
});
const [submitCount, setSubmitCount] = useState(0);
const handleSubmit = (formData: FormData) => {
setDefaults(
Object.fromEntries(
echoedFields.map((field) => [field, String(formData.get(field) ?? '')]),
) as Record<(typeof echoedFields)[number], string>,
);
setSubmitCount((count) => count + 1);
formAction(formData);
};
return (
<section className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Edit invoice</h2>
<form
key={submitCount}
action={handleSubmit}
data-testid="edit-invoice-form"
className="flex flex-col gap-4"
>
<input type="hidden" name="id" defaultValue={invoice.id} />
...
<SubmitButton>Save changes</SubmitButton>
</form>
</section>
);
};

The action hook. useActionState binds updateInvoice and exposes the Result as state, with fieldErrors derived from it exactly as create does.

app/invoices/[invoiceId]/edit-invoice-form.tsx
'use client';
import { useActionState, useState } from 'react';
import { FieldError } from '@/app/_components/field-error';
import { SubmitButton } from '@/app/_components/submit-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { updateInvoice } from '@/lib/invoices/actions';
import type { InvoiceDetail } from '@/lib/invoices/queries';
import { statusSchema } from '@/lib/invoices/schema';
type EditInvoiceFormProps = {
invoice: InvoiceDetail;
customers: { id: string; name: string }[];
};
const dateInputFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// The fields whose typed values are echoed back as defaultValue on a failed
// submit. A `<form action={fn}>` fires requestFormReset on commit under
// react-dom 19, so an invalid edit would otherwise revert the inputs to the
// invoice prop's values; remounting the field cluster on each submit re-applies
// these as the initial uncontrolled values, keeping what the user typed.
const echoedFields = [
'customerId',
'number',
'status',
'total',
'issuedAt',
'dueAt',
'currency',
] as const;
export const EditInvoiceForm = ({
invoice,
customers,
}: EditInvoiceFormProps) => {
const [state, formAction] = useActionState(updateInvoice, null);
const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const [defaults, setDefaults] = useState<
Record<(typeof echoedFields)[number], string>
>({
customerId: invoice.customerId,
number: invoice.number,
status: invoice.status,
total: String(invoice.total),
issuedAt: dateInputFormat.format(invoice.issuedAt),
dueAt: dateInputFormat.format(invoice.dueAt),
currency: invoice.currency,
});
const [submitCount, setSubmitCount] = useState(0);
const handleSubmit = (formData: FormData) => {
setDefaults(
Object.fromEntries(
echoedFields.map((field) => [field, String(formData.get(field) ?? '')]),
) as Record<(typeof echoedFields)[number], string>,
);
setSubmitCount((count) => count + 1);
formAction(formData);
};
return (
<section className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Edit invoice</h2>
<form
key={submitCount}
action={handleSubmit}
data-testid="edit-invoice-form"
className="flex flex-col gap-4"
>
<input type="hidden" name="id" defaultValue={invoice.id} />
...
<SubmitButton>Save changes</SubmitButton>
</form>
</section>
);
};

The prefilled defaults. Every initial defaultValue comes from the invoice prop: number, status, customer, currency as-is; total via String(...) because the column is a string; the two dates through the en-CA formatter.

app/invoices/[invoiceId]/edit-invoice-form.tsx
'use client';
import { useActionState, useState } from 'react';
import { FieldError } from '@/app/_components/field-error';
import { SubmitButton } from '@/app/_components/submit-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { updateInvoice } from '@/lib/invoices/actions';
import type { InvoiceDetail } from '@/lib/invoices/queries';
import { statusSchema } from '@/lib/invoices/schema';
type EditInvoiceFormProps = {
invoice: InvoiceDetail;
customers: { id: string; name: string }[];
};
const dateInputFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// The fields whose typed values are echoed back as defaultValue on a failed
// submit. A `<form action={fn}>` fires requestFormReset on commit under
// react-dom 19, so an invalid edit would otherwise revert the inputs to the
// invoice prop's values; remounting the field cluster on each submit re-applies
// these as the initial uncontrolled values, keeping what the user typed.
const echoedFields = [
'customerId',
'number',
'status',
'total',
'issuedAt',
'dueAt',
'currency',
] as const;
export const EditInvoiceForm = ({
invoice,
customers,
}: EditInvoiceFormProps) => {
const [state, formAction] = useActionState(updateInvoice, null);
const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const [defaults, setDefaults] = useState<
Record<(typeof echoedFields)[number], string>
>({
customerId: invoice.customerId,
number: invoice.number,
status: invoice.status,
total: String(invoice.total),
issuedAt: dateInputFormat.format(invoice.issuedAt),
dueAt: dateInputFormat.format(invoice.dueAt),
currency: invoice.currency,
});
const [submitCount, setSubmitCount] = useState(0);
const handleSubmit = (formData: FormData) => {
setDefaults(
Object.fromEntries(
echoedFields.map((field) => [field, String(formData.get(field) ?? '')]),
) as Record<(typeof echoedFields)[number], string>,
);
setSubmitCount((count) => count + 1);
formAction(formData);
};
return (
<section className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Edit invoice</h2>
<form
key={submitCount}
action={handleSubmit}
data-testid="edit-invoice-form"
className="flex flex-col gap-4"
>
<input type="hidden" name="id" defaultValue={invoice.id} />
...
<SubmitButton>Save changes</SubmitButton>
</form>
</section>
);
};

The hidden id. What the update schema requires and what the action’s where targets. Without it the save has no row to change.

1 / 1

Two details earn a name. The inputs use defaultValue, not value, and that uncontrolled choice does double duty: on a successful save revalidatePath re-runs the page, the reloaded invoice arrives with the new values, and React flows them in as fresh defaults — controlled inputs would freeze at client state and force you to hand-sync against the refetch. The duplicate-number conflict surfaces in the form-level banner, not under the number field, because the unique constraint is on the (organizationId, number) pair: the violation belongs to the combination, not to any single field. The tests check for that placement.

And one deliberate absence: no useOptimistic here, because the user already sees the form and the in-place save is too small for the perceived-speed win to matter. Optimistic create is where it lands.

The tests hit a real Postgres, so bring the database up, migrate, and seed it first:

Terminal window
docker compose up -d
pnpm db:migrate
pnpm db:seed

Then run the lesson’s suite:

Terminal window
pnpm test:lesson 3

It drives updateInvoice and renders EditInvoiceForm against four cases: the form opens with the invoice’s values as input defaults; a valid save updates the row and returns ok({ id }) with no redirect; an Acme-context save aimed at a Globex invoice’s id leaves that row untouched (the tenant guard in the where); and a duplicate number comes back as a conflict Result, not a number field error. When the edit path is wired correctly the suite passes:

✓ tests/lessons/Lesson 3.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)

The tests cover the server contract and the first paint, but not the live re-render or the typed-value echo. Confirm those by hand:

Open an invoice, change a field, and save — the page shows the new value with no manual reload (revalidatePath re-fetched the Server Component).
untested
Set the number to one another invoice in the same org already uses, then save — a banner appears across the top of the form, not a message under the number field.
untested
Submit an invalid edit (temporarily drop required from a field to reach the server, or clear the total) — a message renders under the offending field and the values you typed in the other fields stay put. Restore required afterward.
untested