Archive, restore, and delete
The reads route on lifecycle state, but every row is frozen — there’s no way to move one between states. This lesson ships the writes: archive a row, restore it, and (as an admin) soft-delete it.
Archive a row from the Active tab and it vanishes the instant you click, then reappears under Archived with an “Archived on …” label and a Restore button. Restore returns it to Active. An admin’s soft-delete drops the row from the default list and surfaces it under All with a “Deleted” badge. Open the inspector as you work and watch its row-counts banner and audit tail move together after every action.
Your mission
Section titled “Your mission”You ship three actions — archiveInvoice, restoreInvoice, softDeleteInvoice — that move a row between the lifecycle states you routed reads onto last lesson.
Each is a precondition-checked, audited transition, not a bare field set.
Each is wrapped with authedAction, so role enforcement and Zod parsing run at the boundary before your code does.
Archive and restore are open to a member; soft-delete is gated to admin at the action, not just in the menu — hiding the Delete item is cosmetic, since a member who forges the request still hits the wrapper.
Tenancy comes from the session ctx, never the request: a row loaded with findInvoice(ctx.orgId, id) from another org is simply not found.
Before any field changes, each action checks an id+version precondition.
The version bumps on every write; if the version the client sent no longer matches the row, a stale tab lost the race, and the action returns an honest conflict instead of clobbering.
The audit write rides in the same atomic step as the mutation — two statements back to back here, one db.transaction in real Postgres.
A state change without its audit row, or an audit row without its state change, is a bug; a refused action writes neither.
Archive uses useOptimistic so the row leaves the table the moment it’s clicked rather than after the round-trip.
The optimistic value expires when the transition ends — it is never committed.
On success the revalidated rows no longer carry it, so it stays gone; on { ok: false } the rows are unchanged and it reappears.
The conflict surfaces here only as a toast; the richer “Use latest / Overwrite anyway” banner is the next lesson’s job.
One payoff to watch for once it runs: the seed ships a live ACME-1001 and a soft-deleted ACME-1001.
Two invoices, same number, both legal, because the partial unique index is scoped (orgId, number) WHERE deleted_at IS NULL — soft-deleting a number frees it for reuse.
The inspector’s index panel spells out the constraint.
version, or a row already in the target state, returns a conflict instead of a silent state change.{ ok: false }.ACME-1001 — confirms the number reuse the partial unique index permits.Coding time
Section titled “Coding time”Implement the three lifecycle actions in src/lib/invoices/actions.ts and wire the row-action menu in table.tsx against the brief and the tests.
Try it before you open the solution: the precondition and the audit write are the reflex this lesson builds.
Reference solution and walkthrough
The three actions
Section titled “The three actions”All three live in src/lib/invoices/actions.ts, alongside updateInvoice, sharing one input schema and one shape: load, check, mutate, audit, revalidate.
The schema only needs to identify the row and carry the version the client last saw:
const lifecycle = z.strictObject({ id: z.string(), version: z.coerce.number().int(),});z.coerce because the row menu submits FormData, whose values are always strings: the version arrives as "3", not 3.
This is the same boundary coercion from Coercing FormData strings.
Here is archive, in its four parts:
const archive = async ( input: z.infer<typeof lifecycle>, ctx: AuthedCtx,): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row) { return err('not_found', 'Invoice not found.'); } if ( row.version !== input.version || row.archivedAt !== null || row.deletedAt !== null ) { return conflict(CONFLICT_MESSAGE, row); }
row.archivedAt = new Date().toISOString(); row.version += 1; pushAudit({ orgId: ctx.orgId, actorUserId: ctx.userId, action: 'invoice.archive', subjectId: row.id, });
revalidatePath('/invoices'); return ok(row);};Load the row, scoped to the session’s org.
The orgId comes from ctx, so a row from another tenant is not found, as is a missing one.
const archive = async ( input: z.infer<typeof lifecycle>, ctx: AuthedCtx,): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row) { return err('not_found', 'Invoice not found.'); } if ( row.version !== input.version || row.archivedAt !== null || row.deletedAt !== null ) { return conflict(CONFLICT_MESSAGE, row); }
row.archivedAt = new Date().toISOString(); row.version += 1; pushAudit({ orgId: ctx.orgId, actorUserId: ctx.userId, action: 'invoice.archive', subjectId: row.id, });
revalidatePath('/invoices'); return ok(row);};The precondition, checked before any mutation.
Refuse if the version drifted or the row is already archived or deleted: only an active row archives.
A lost race returns conflict(...) carrying the row the server holds now, so the client reacts in one round trip.
const archive = async ( input: z.infer<typeof lifecycle>, ctx: AuthedCtx,): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row) { return err('not_found', 'Invoice not found.'); } if ( row.version !== input.version || row.archivedAt !== null || row.deletedAt !== null ) { return conflict(CONFLICT_MESSAGE, row); }
row.archivedAt = new Date().toISOString(); row.version += 1; pushAudit({ orgId: ctx.orgId, actorUserId: ctx.userId, action: 'invoice.archive', subjectId: row.id, });
revalidatePath('/invoices'); return ok(row);};Set archivedAt and bump version.
The bump makes the next precondition check honest: a tab still holding the old number is now stale by one.
const archive = async ( input: z.infer<typeof lifecycle>, ctx: AuthedCtx,): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row) { return err('not_found', 'Invoice not found.'); } if ( row.version !== input.version || row.archivedAt !== null || row.deletedAt !== null ) { return conflict(CONFLICT_MESSAGE, row); }
row.archivedAt = new Date().toISOString(); row.version += 1; pushAudit({ orgId: ctx.orgId, actorUserId: ctx.userId, action: 'invoice.archive', subjectId: row.id, });
revalidatePath('/invoices'); return ok(row);};Write the audit row in the same step as the mutation.
'invoice.archive' names the event and subjectId ties it to the row.
revalidatePath then refreshes the server-rendered list, and ok(row) returns the new state.
restore and softDelete are the same skeleton with different preconditions and field writes.
Restore is the interesting one: it clears whichever flag is set.
const restore = async ( input: z.infer<typeof lifecycle>, ctx: AuthedCtx,): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row) { return err('not_found', 'Invoice not found.'); } if ( row.version !== input.version || (row.archivedAt === null && row.deletedAt === null) ) { return conflict(CONFLICT_MESSAGE, row); }
row.archivedAt = null; row.deletedAt = null; row.version += 1; pushAudit({ orgId: ctx.orgId, actorUserId: ctx.userId, action: 'invoice.restore', subjectId: row.id, });
revalidatePath('/invoices'); return ok(row);};One action handles both an archived row and a soft-deleted one by branching on state instead of splitting in two. The precondition refuses an already-live row; on success it clears both flags unconditionally. That is why the admin’s “Restore deleted” menu item reuses the same dispatcher as Archived’s “Restore”.
softDelete sets deletedAt, refuses an already-deleted row, and writes 'invoice.delete':
const softDelete = async ( input: z.infer<typeof lifecycle>, ctx: AuthedCtx,): Promise<Result<Invoice>> => { const row = findInvoice(ctx.orgId, input.id); if (!row) { return err('not_found', 'Invoice not found.'); } if (row.version !== input.version || row.deletedAt !== null) { return conflict(CONFLICT_MESSAGE, row); }
row.deletedAt = new Date().toISOString(); row.version += 1; pushAudit({ orgId: ctx.orgId, actorUserId: ctx.userId, action: 'invoice.delete', subjectId: row.id, });
revalidatePath('/invoices'); return ok(row);};There is no role check inside softDelete’s body. The admin gate is on the wrapper:
export const archiveInvoice = authedAction('member', lifecycle, archive);export const restoreInvoice = authedAction('member', lifecycle, restore);// Soft-delete is admin-gated at the action — the RBAC gate lives here, not only// in the UI (hiding the menu item is cosmetic on top of this).export const softDeleteInvoice = authedAction('admin', lifecycle, softDelete);The wrapper runs roleAtLeast before it ever calls the function, so a member’s forged soft-delete is refused at the boundary and the body never sees it.
Wiring the row menu
Section titled “Wiring the row menu”table.tsx is a Client Component.
Its row-action menu already renders an Edit link; you add Archive, Restore, Restore deleted, and Delete, each dispatching the right action and gated on the row’s state plus the viewer’s role.
The order of the wiring matters, so here it is part by part.
const [visibleRows, archiveOptimistic] = useOptimistic( rows, (current: Invoice[], removedId: string) => current.filter((row) => row.id !== removedId),);
const [archiveState, archiveDispatch] = useActionState(archiveInvoice, null);const [restoreState, restoreDispatch] = useActionState(restoreInvoice, null);const [deleteState, deleteDispatch] = useActionState(softDeleteInvoice, null);
const [, startArchive] = useTransition();
useResultToast(archiveState, 'Invoice archived.');useResultToast(restoreState, 'Invoice restored.');useResultToast(deleteState, 'Invoice deleted.');
const onArchive = (row: Invoice) => { startArchive(() => { archiveOptimistic(row.id); archiveDispatch(lifecycleFormData(row)); });};useOptimistic derives visibleRows from rows, dropping the archived id.
Render visibleRows in the table, not rows.
The optimistic value expires when the transition ends, leaving the revalidated rows as the truth.
const [visibleRows, archiveOptimistic] = useOptimistic( rows, (current: Invoice[], removedId: string) => current.filter((row) => row.id !== removedId),);
const [archiveState, archiveDispatch] = useActionState(archiveInvoice, null);const [restoreState, restoreDispatch] = useActionState(restoreInvoice, null);const [deleteState, deleteDispatch] = useActionState(softDeleteInvoice, null);
const [, startArchive] = useTransition();
useResultToast(archiveState, 'Invoice archived.');useResultToast(restoreState, 'Invoice restored.');useResultToast(deleteState, 'Invoice deleted.');
const onArchive = (row: Invoice) => { startArchive(() => { archiveOptimistic(row.id); archiveDispatch(lifecycleFormData(row)); });};One useActionState per action, lifted to the table, not the row.
The archived row is about to leave visibleRows; if its action state lived on the row, the result and its toast would unmount with it.
const [visibleRows, archiveOptimistic] = useOptimistic( rows, (current: Invoice[], removedId: string) => current.filter((row) => row.id !== removedId),);
const [archiveState, archiveDispatch] = useActionState(archiveInvoice, null);const [restoreState, restoreDispatch] = useActionState(restoreInvoice, null);const [deleteState, deleteDispatch] = useActionState(softDeleteInvoice, null);
const [, startArchive] = useTransition();
useResultToast(archiveState, 'Invoice archived.');useResultToast(restoreState, 'Invoice restored.');useResultToast(deleteState, 'Invoice deleted.');
const onArchive = (row: Invoice) => { startArchive(() => { archiveOptimistic(row.id); archiveDispatch(lifecycleFormData(row)); });};An explicit useTransition.
The menu’s onSelect is a plain event handler, not a form action, and React rejects an optimistic update applied outside a transition, so archive’s optimistic write and its dispatch share this one.
const [visibleRows, archiveOptimistic] = useOptimistic( rows, (current: Invoice[], removedId: string) => current.filter((row) => row.id !== removedId),);
const [archiveState, archiveDispatch] = useActionState(archiveInvoice, null);const [restoreState, restoreDispatch] = useActionState(restoreInvoice, null);const [deleteState, deleteDispatch] = useActionState(softDeleteInvoice, null);
const [, startArchive] = useTransition();
useResultToast(archiveState, 'Invoice archived.');useResultToast(restoreState, 'Invoice restored.');useResultToast(deleteState, 'Invoice deleted.');
const onArchive = (row: Invoice) => { startArchive(() => { archiveOptimistic(row.id); archiveDispatch(lifecycleFormData(row)); });};One toast per resolved Result.
useResultToast fires the success line on ok, the conflict line on a stale precondition, and the server’s userMessage on any other refusal.
const [visibleRows, archiveOptimistic] = useOptimistic( rows, (current: Invoice[], removedId: string) => current.filter((row) => row.id !== removedId),);
const [archiveState, archiveDispatch] = useActionState(archiveInvoice, null);const [restoreState, restoreDispatch] = useActionState(restoreInvoice, null);const [deleteState, deleteDispatch] = useActionState(softDeleteInvoice, null);
const [, startArchive] = useTransition();
useResultToast(archiveState, 'Invoice archived.');useResultToast(restoreState, 'Invoice restored.');useResultToast(deleteState, 'Invoice deleted.');
const onArchive = (row: Invoice) => { startArchive(() => { archiveOptimistic(row.id); archiveDispatch(lifecycleFormData(row)); });};The two calls fire together inside startArchive: drop the row optimistically, then dispatch the real action.
When it settles, the revalidated rows confirm the removal, or on { ok: false } the unchanged rows bring the row back.
useResultToast and lifecycleFormData are small helpers in the same file.
The toast hook reads each settled Result and picks the line:
const useResultToast = ( state: Result<Invoice> | null, successMessage: string,) => { useEffect(() => { if (!state) { return; } if (state.ok) { toast.success(successMessage); return; } toast.error( state.error.code === 'conflict' ? 'This invoice changed elsewhere — refresh to retry.' : state.error.userMessage, ); }, [state, successMessage]);};lifecycleFormData builds the id+version FormData each dispatcher expects:
const lifecycleFormData = (row: Invoice) => { const formData = new FormData(); formData.set('id', row.id); formData.set('version', String(row.version)); return formData;};The menu items gate on the row’s state and the viewer’s role, computed once per row:
const isActive = row.deletedAt === null && row.archivedAt === null;const canDelete = isActive && role === 'admin';const canRestore = row.archivedAt !== null && row.deletedAt === null;const canUndelete = row.deletedAt !== null && role === 'admin';Archive shows on an active row, Restore on an archived one; Restore deleted and Delete show only for an admin, on a deleted and an active row respectively.
Restore and Restore deleted both call restoreDispatch, the one action that clears whichever flag is set.
Here is the optimistic Archive item, alongside the dispatch-only Restore and Delete that share its onSelect shape:
{isActive ? ( <> <DropdownMenuSeparator /> <DropdownMenuItem data-testid="row-action-archive" onSelect={() => onArchive(row)} > Archive </DropdownMenuItem> </>) : null}{canRestore ? ( <> <DropdownMenuSeparator /> <DropdownMenuItem data-testid="row-action-restore" onSelect={() => restoreDispatch(lifecycleFormData(row))} > Restore </DropdownMenuItem> </>) : null}{canDelete ? ( <> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive" data-testid="row-action-delete" onSelect={() => deleteDispatch(lifecycleFormData(row))} > Delete </DropdownMenuItem> </>) : null}Archive routes through onArchive for the optimistic removal; Restore and Delete dispatch straight away, since revalidation is what pulls their rows out of the view.
Every item carries a data-testid so the tests and the inspector can find it.
The official hook reference, including the optimistic-delete-with-error-recovery example that mirrors this Archive wiring.
The per-action state hook you lift to the table, with its pairing-with-useOptimistic and error-handling sections.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite.
pnpm test:lesson 4A green Vitest run proves the three actions hold their preconditions, write the right audit rows, enforce the admin gate, and reject a stale version with a conflict.
The tests check observable Result and store outcomes, so they miss the optimistic flicker, the menu gating, and the seed’s colliding pair: confirm those by hand, using the inspector’s identity switcher and its row-counts and audit-tail panels.
invoice.archive event.invoice.restore event.org-acme:member in the inspector and confirm the Delete control is absent.ACME-1001 — and read the inspector’s index panel for why the partial unique index permits the reuse.A lifecycle conflict surfaces only as a toast here; the richer resolve-it banner comes next, on the edit path.