Archive, restore, and delete
The reads now route on lifecycle state, but every row in the table is frozen — there’s no way to move one between states. In this lesson you ship the writes: archive a row, restore it, and (as an admin) soft-delete it, with every move recorded in the audit trail.
Archiving a row from the Active tab makes it vanish from the table the instant you click, then reappear 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 wearing a “Deleted” badge. Open the inspector while you do this and watch its row-counts banner and audit tail move together after every action — the audit entry is written in the same step as the state change, never after, never not at all.
Your mission
Section titled “Your mission”The previous lesson made the scoped helper’s active() / archived() / includingDeleted() views honest and routed the reads onto them. This lesson adds the writes that move a row between those states. There are three of them — archiveInvoice, restoreInvoice, softDeleteInvoice — and a real engineer treats every one of them as a precondition-checked, audited transition, not a bare field set. That reflex is the whole lesson.
Each action is wrapped with authedAction, so role enforcement and Zod parsing happen at the boundary before your code runs — the same wrapper you met in The authedAction wrapper. 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 from a member’s UI is cosmetic — a member who forges the request still has to get past the wrapper, and that’s where the real gate lives. Tenancy is never hand-typed either: the org comes from the session ctx, so a row you load with findInvoice(ctx.orgId, id) from another org is simply not found. You never write where orgId = <a value from the request>.
Before any field changes, each action checks an id+version precondition. A row carries a version number that bumps on every write; if the version the client sent no longer matches the row, the row changed under them — 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: in this in-memory store that’s two statements back to back, but in real Postgres it is one db.transaction, and the discipline is identical — a state change without its audit row, or an audit row without its state change, is a bug. A refused action writes neither.
For archive you’ll reach for useOptimistic so the row leaves the table the moment it’s clicked, rather than after the server round-trip. The subtlety, and the reason this is worth doing carefully, is that the optimistic value expires when the transition ends — it is never committed. On a successful archive the revalidated rows no longer carry the row, so it stays gone; on { ok: false } the rows are unchanged and the row simply reappears. This is expiry, not a throw-triggered rollback — the same distinction drawn in Version columns and the honest 409. The conflict surfaces here only as a toast; the richer “Use latest / Overwrite anyway” banner is the edit path’s job, which you’ll build in the next lesson.
One bonus payoff to look for once it runs: the seed ships a colliding pair — 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 reflex you’re building here only sticks if you reach for the precondition and the audit write yourself first.
Reference solution and walkthrough
The three actions
Section titled “The three actions”All three lifecycle actions live in src/lib/invoices/actions.ts, alongside updateInvoice (which you’ll harden in the next lesson). They share one input schema and one shape: load, check, mutate, audit, revalidate.
The schema is tiny — a lifecycle action 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, and FormData values are always strings — the version arrives as "3", not 3. The same boundary coercion you saw in Crossing the FormData boundary.
Here is archive, walked through its four load-bearing 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, never from the request — a row from another tenant is simply not found, so tenant isolation holds at the write without a single hand-typed filter. A missing row is an honest not_found.
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 it’s deleted — only an active row archives. A lost race returns conflict(...) carrying the row the server holds now, so the client can react in one round trip. No mutation happened, so there’s nothing to roll back.
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. Every lifecycle write increments the version so the next precondition check on this row is honest — the number a later tab is holding is now stale by exactly 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; subjectId ties it to the row. In Postgres the field write and this insert would share one db.transaction — here they’re back-to-back statements, but the rule is the same: the audit rides with the change or not at all. 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 restores both an archived row and a soft-deleted one, because it branches on the row’s state instead of splitting into two. The precondition refuses an already-live row — restoring something that’s already active is itself a conflict — and on success it clears both flags unconditionally. That’s why the admin’s “Restore deleted” menu item can reuse the very same dispatcher as Archived’s “Restore”: there’s nothing to special-case.
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);};Notice 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);Archive and restore pass 'member'; soft-delete passes 'admin'. The wrapper runs roleAtLeast before it ever calls the function, so a member’s forged soft-delete is refused at the boundary — the body never sees it. This is the difference between a gate and a decoration: the menu hides the Delete item from members for tidiness, but the action is what makes it true.
Three decisions worth naming, because they’re the ones an inexperienced dev gets wrong:
- The precondition comes before the mutation. Check
versionand lifecycle state first, return the conflict, and only then touch a field. If you mutate first and check after, a lost race has already corrupted the row. This is the optimistic-concurrency guard from Version columns and the honest 409. - The audit write shares the action’s atomic step. It is not a
then, not anafter(), not a best-effort log. The state change and its audit entry are one unit — and a refused action, which returns before the mutation, writes neither. - The org filter is
ctx.orgId, not a request field. Tenant isolation belongs at the write, derived from the trusted session, so no payload can ask for another org’s row.
Wiring the row menu
Section titled “Wiring the row menu”table.tsx is a Client Component. The row-action menu already renders an Edit link; you’re adding 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 is never committed — it 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 lifecycle action, lifted to the table, not the row. The row that triggered an archive is about to vanish from visibleRows — if its action state lived on the row, the result and its toast would unmount with it. At the table level they survive.
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 an optimistic update applied outside a transition is rejected by React. So archive’s optimistic write and its dispatch must share this transition.
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 a success line on ok, the conflict line on a stale precondition, and the server’s userMessage on any other refusal. This is where the lost-race conflict surfaces in this lesson — a toast, not yet a banner.
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. The row leaves the table immediately; when the action settles, either the revalidated rows confirm the removal or — on { ok: false } — the unchanged rows bring it 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]);};And 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 when the row isActive; Restore when it’s archived but not deleted; Restore deleted when it’s deleted and the viewer is admin; Delete when it’s active and the viewer is admin. Restore and Restore deleted both call restoreDispatch — the one action that clears whichever flag is set.
Here’s how one item looks — the optimistic Archive — and how the dispatch-only Restore and Delete items follow the same 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 so it gets the optimistic removal; Restore and Delete dispatch straight away, since they don’t pull the row out of the table the same way (a restored row leaves the current view by revalidation, a deleted one too). 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 means the three actions hold: archive sets archivedAt and bumps version and pushes exactly one audit row; restore clears whichever flag is set; soft-delete sets deletedAt; the admin gate on softDeleteInvoice refuses a member; a stale version returns a conflict; and a refused action writes no audit row. All six tested requirements report pass.
The tests assert against the actions’ observable Result and store outcomes — they can’t watch the optimistic flicker, the menu gating, or the seed’s colliding pair. Confirm those by hand, using the inspector’s identity switcher and its row-counts + 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.One thing this lesson deliberately leaves on the table: a lifecycle conflict surfaces only as a toast. The richer banner — current server values, “Use latest”, admin-gated “Overwrite anyway” — belongs to the edit path, and that’s the next lesson, where you add the same version precondition to updateInvoice and turn its silent last-write-wins into a real conflict the user can resolve.