Skip to content
Chapter 61Lesson 1

Two timestamps, three actions

Model a row's lifecycle with soft delete and archive — two nullable timestamp columns and three Server Actions that move a row between active, archived, and deleted instead of removing it.

A user clicks Delete invoice. Before you reach for DELETE FROM, ask three questions: what vanishes from the screen, what survives in the database, and what can come back. Now a second click: the user finishes a project and wants it off their active list but not gone, in case they need it next quarter. Same button? Same column? Same promise?

The two look identical in the UI and behave nothing alike underneath. This lesson rests on two facts: most “deletes” a user clicks are writes, not row removals; and “archive” and “delete” are different promises even when they touch the same column. By the end you’ll have a two-timestamp schema and three Server Actions, softDelete, archive, and restore, that drive the lifecycle of every row a user can touch.

Two decisions hide inside the word “delete.” First: when the user removes something, does the row actually leave the table?

A hard delete is the literal thing: DELETE FROM invoices WHERE id = …. The row is gone, foreign keys cascade or block, and your only way back is a backup restore — which in practice means it isn’t coming back.

A soft delete is quieter: UPDATE invoices SET deleted_at = now() WHERE id = …. The row stays, fully intact, with one new fact attached — the moment it was deleted. Restoring it just clears the stamp. The audit trail survives, and nothing referencing the row broke, because nothing was removed.

One sentence carries the rest of the chapter: soft delete is a write, not a delete. The instant a “delete” becomes an UPDATE, the row stays visible to anything that doesn’t explicitly hide it, so every read you’ve written is a place it can leak back into view.

await db
.delete(invoices)
.where(eq(invoices.id, invoiceId));

The row is gone. Recovery is from backup only, which is to say, not really.

Which to reach for? The trigger is re-reference and consequences, not taste. Soft-delete anything a user might point at again or that carries a compliance obligation: invoices, projects, customers, the comment on a thread. Hard-delete throwaway rows no one re-references: session rows, password-reset tokens, unnamed draft autosaves, expired email-verification records. Treat it as a threshold: the more a record is woven into the rest of the system, the further it tilts toward soft delete.

Drop each record into the deletion strategy you'd ship it with. Lean on the trigger, not the list: would a user (or an auditor) ever point at this row again? Drag each item into the bucket it belongs to, then press Check.

Soft delete Re-referenced or casts a compliance shadow — keep the row, stamp it
Hard delete Transient artifact no one re-references — drop the row
A paid invoice
A customer record
A user’s comment on a thread
A password-reset token
An expired email-verification row
A draft autosave the user never named

This second decision is the one people get wrong. Whether the row stays in the table is settled; this is about what you promise the user.

A soft delete is invisible. The user clicks Delete, the row drops out of every list they can see, and it’s gone as far as they know. It resurfaces only through an admin or a hidden “deleted items” view most products never build. Restoring isn’t part of the user’s day; it’s a recovery action for “we removed that by mistake, can you get it back?”

An archive is explicit. The user clicks Archive, and the row moves to an “Archived” tab they can open whenever they like. Restoring is a first-class action they take themselves; they expect to see it again, and you’d break that contract if they couldn’t.

Both often share the same column shape: deletedAt and archivedAt are nullable timestamps. Same storage primitive, different product contract, surfaced through different parts of the UI. The database can’t tell you which you meant — only the product decision can, and real apps land in one of three shapes:

  • Archive-only. Every “remove” is an archive, with no user-facing delete. This is Notion pages and Linear projects: archive is the strongest “remove” the UI offers.
  • Soft-delete-only. One “Delete” button, the row disappears, and restoring is admin-only. There’s no archive concept.
  • Both. Archive means “I’m done with this”; soft delete is the recovery net under “I deleted this by mistake.” Two intentions, two surfaces.

This course commits to both: archive is the primary user surface, soft delete is the admin recovery surface. Users get a self-service lifecycle they control (archive, browse, restore); underneath sits a recovery net that never clutters their view.

stateDiagram-v2
  direction LR
  state "Soft-deleted" as Soft
  state "Hard-deleted" as Hard
  [*] --> Active
  Active --> Archived : archive
  Archived --> Active : restore
  Active --> Soft : delete (softDelete)
  Archived --> Soft : delete (softDelete)
  Soft --> Active : restore
  Soft --> Hard : hard delete
  Hard --> [*]
  classDef terminal fill:#fde2e2,stroke:#dc2626,color:#7f1d1d
  class Hard terminal

A row’s state is derived from two timestamps (deletedAt, archivedAt); the three actions are the edges that move it. The Archived → Soft-deleted path shows that an archived row can still be deleted, with delete overriding archive. Hard-deleted is the one exit with no way back.

This is the lesson title made literal: two timestamps, three actions. A row’s state isn’t a field you look up; archive, softDelete, and restore are the edges that move it. Follow Archived straight to Soft-deleted: a row can be archived and then deleted, both timestamps set at once, with deleted winning as the effective state. These aren’t mutually exclusive flags — they’re a small state machine.

A user clicks Archive on a project, then goes looking for it again the next day. Pick the description that matches what actually happened in the database and what the user can do next.

The project’s row never left the table — one timestamp column flipped from null to a time — and it now lives under an Archived tab the user can open and restore from on their own.
The project vanishes from every list, the row is still in the table, but only an admin can bring it back — the user has no way to do it themselves.
The row is gone from the table; getting the project back means restoring a database backup.
Nothing was written — “Archived” is just a label the browser hides the row behind, so the state evaporates on the next request from another device.

Two nullable timestamps, spread into every table

Section titled “Two nullable timestamps, spread into every table”

Three lifecycle positions map to two nullable timestamps: a set timestamp marks when the row entered that state, and the absence of one is the third position.

The alternative is a single status enum (active | archived | deleted) plus separate audit timestamps. Two nullable timestamps win: the schema is simpler, the column names document themselves (deletedAt is the deletion timestamp), and the index story is identical either way. The deciding factor is that a row can be archived and deleted at once, which an enum forces you to flatten into one value and lose. Optimize the schema for the queries and UI it serves, not for one tidy column.

Every entity table gets this lifecycle: invoices, projects, customers. Repeating three column definitions across a dozen tables drifts out of sync the moment someone edits one copy, so declare the shape once in a lifecycleColumns helper and spread it into each table.

const lifecycleColumns = {
deletedAt: timestamp({ withTimezone: true }),
archivedAt: timestamp({ withTimezone: true }),
updatedAt: timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
};
export const invoices = pgTable('invoices', {
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid().notNull(),
amount: numeric({ precision: 12, scale: 2 }).notNull(),
...lifecycleColumns,
});

Both are nullable, and the nullability is the encoding: null means the row is not in that state, a set timestamp means it entered that state at that moment. A lifecycle change just flips a column from null to a time; nothing is ever removed.

const lifecycleColumns = {
deletedAt: timestamp({ withTimezone: true }),
archivedAt: timestamp({ withTimezone: true }),
updatedAt: timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
};
export const invoices = pgTable('invoices', {
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid().notNull(),
amount: numeric({ precision: 12, scale: 2 }).notNull(),
...lifecycleColumns,
});

updatedAt records when the row last changed. $onUpdate stamps it on every UPDATE drizzle-orm issues, so you never set it by hand and a raw SQL UPDATE that bypasses Drizzle won’t touch it. The new Date() is the sanctioned exception to the “no Date in domain code” rule: this is the Drizzle storage seam, where Date is the boundary type. A later lesson uses updatedAt to make concurrent edits honest.

const lifecycleColumns = {
deletedAt: timestamp({ withTimezone: true }),
archivedAt: timestamp({ withTimezone: true }),
updatedAt: timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
};
export const invoices = pgTable('invoices', {
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid().notNull(),
amount: numeric({ precision: 12, scale: 2 }).notNull(),
...lifecycleColumns,
});

One spread gives invoices the full lifecycle shape, and every other table gets the same line. Change the lifecycle once and every table follows.

1 / 1

Columns are declared in camelCase (deletedAt) but stored in snake_case (deleted_at). The mapping is set once on the Drizzle client (casing: 'snake_case'), so you write the TypeScript name and the SQL name follows. And timestamp({ withTimezone: true }) maps to Postgres timestamptz, the only timestamp type you should store an instant in.

The editor below runs a real Postgres in your browser. It has no client-level casing, so spell the column names out in snake_case and use a plain integer key; the lifecycle columns are identical to the project’s.

Add the three lifecycle columns to invoices: a nullable deleted_at, a nullable archived_at, and a non-null updated_at — each a withTimezone timestamp. deleted_at and archived_at stay nullable (a null means the row isn't in that state); updated_at is .notNull(). This editor has no client-level casing, so spell column names out in snake_case — the lifecycle shape is identical to the project's.

The three actions: softDelete, archive, restore

Section titled “The three actions: softDelete, archive, restore”

The schema gives a row its possible states; these three Server Actions are the only sanctioned way to move between them.

All three return Result<T>, the { ok: true, data } | { ok: false, error } union every action in the course returns, and each body is tiny:

  • softDelete sets deletedAt = now().
  • archive sets archivedAt = now().
  • restore clears whichever timestamp is set, back to null.

Each body is about three lines because authedAction(role, schema, fn) does the surrounding work: it runs the session lookup, the role check, and the Zod parse, then hands the body a ctx.db that is already tenantDb(orgId), scoped to the active org’s rows. The body neither parses, authorizes, nor re-derives the org. It reaches for ctx.db and writes, so the bespoke part shrinks to one update.

export const softDeleteInvoice = authedAction(
'member',
z.object({ id: z.uuid() }),
async (input, ctx) => {
await ctx.db
.update(invoices)
.set({ deletedAt: sql`now()` })
.where(eq(invoices.id, input.id));
revalidatePath('/invoices');
return ok(null);
},
);

Stamps deletedAt with the current time. The row leaves every user-facing list, and an admin can still recover it.

The where clause carries only the id; ctx.db folds in the org scope. Four shared identifiers each reward a hover:

export const softDeleteInvoice = authedAction(
'member',
z.object({ id: z.uuid() }),
async (input, ctx) => {
await ctx.db
.update(invoices)
.set({ deletedAt: sql`now()` })
.where(eq(invoices.id, input.id));
revalidatePath('/invoices');
return ok(null);
},
);

Every Server Action in the course follows five seams: parse, authorize, mutate, revalidate, return. The wrappers own parse and authorize, the body owns mutate, and revalidatePath fires the revalidate before the return. Skip the revalidate and the row you just soft-deleted stays on screen, because nothing told the cached list to re-fetch: a delete that doesn’t visibly delete, and a one-line bug.

restore holds the one non-obvious decision in the trio.

A user double-clicks Restore on a row that was already active. The second call runs set({ deletedAt: null, archivedAt: null }) against a row whose timestamps were already null, so the UPDATE touches no real state — and the action still returns ok. Why is ok, not an error, the right return for that second call?

restore is written to be idempotent: it declares a target state — “this row is active” — and that state already holds, so repeating the call is a safe no-op and reporting success is the honest answer.
An error would describe the second call more precisely, but the Result union has no variant that can express “the row was already in the requested state.”
Overwriting a column with the value it already holds still counts as a real change, so the second call isn’t actually a no-op and there’s nothing to special-case.
A Server Action that issues a syntactically valid UPDATE is required by the framework to resolve as ok, regardless of how many rows it changed.

From actions to affordances, and the status filter

Section titled “From actions to affordances, and the status filter”

Each lifecycle state gets exactly one affordance:

  • Archive: a button on every active row, plus a bulk action across selected rows, since users archive in batches.
  • Restore: a button on rows in the Archived tab, where users look for what they put away.
  • Hard delete: a destructive button in the archive view, behind a confirm dialog and usually restricted by role. Never the default row action, since permanent deletion should take a deliberate, confirmed step.

Which list a user sees is URL state, the same shape you built in the previous chapter. The filter is tri-state: active is the default and hides archived and deleted rows, archived is the Archived tab, and all is a gated admin view that includes soft-deleted rows. You read ?status=archived through the same parseAsStringEnum(['active', 'archived', 'all']).withDefault('active') parser you wrote for filters, now pointed at the lifecycle.

The tab the user is on is just URL state: ?status=archived is the whole difference between the Active and Archived lists. A row’s actions depend on the lifecycle state it is in, and the user sees the label “Archived” with a way back, never the raw deletedAt. Delete is the destructive, confirm-gated path, restricted by role, never the default row action.

To the user, a row is one of two things: gone from the UI entirely (soft delete), or labeled “Archived” with a way back. Never a timestamp, and never the word “deleted” on a row they can still see.

Clearing the timestamp returns the row to Active, and everything attached comes back with it, because nothing was ever disconnected. Foreign keys still point where they pointed; child rows are still children. Soft delete was a write, not a delete, so there is nothing to reconnect.

Restore inherits whatever your delete did, which raises two traps.

await ctx.db
.update(invoices)
.set({ deletedAt: null, archivedAt: null })
// line items were never deleted — they survived the soft delete and snap back with the parent
.where(eq(invoices.id, input.id));

The first is cascading restore: bringing an invoice’s line items back with it. The rule is symmetry — cascade the restore exactly when you cascaded the soft delete. If deleting the parent stamped the children, restoring it must clear them, or the data ends up half-restored.

The second is the orphan, in two forms. Children hard-deleted alongside a soft-deleted parent cannot return, leaving a parent missing its pieces — the strongest argument for cascading soft deletes together. Subtler: restore a child whose parent is still soft-deleted and you get a row visible in the UI while its parent is not. Guard by checking the parent’s state before restoring. Surfacing that conflict cleanly — telling the user the parent is gone rather than silently making an orphan — is concurrency machinery a later lesson builds.

Soft delete is a write: the cost comes due

Section titled “Soft delete is a write: the cost comes due”

“Soft delete is a write, not a delete” has two structural consequences. The fix for each lands in a later lesson, once you’ve felt the problem.

Consequence one: every read becomes a code path that can leak. The row is still in the table, so any query that doesn’t explicitly exclude it pulls it back. A count missing deletedAt IS NULL runs too high; a join from customers to invoices that forgets it drags deleted rows into the result. The list, the detail page, the monthly report each have to remember the filter, and each looks correct when it forgets, because the bug is in what isn’t there and reads exactly like the right query. In a multi-tenant app the worst version misses the lifecycle filter and the tenancy filter at once, surfacing one customer’s deleted row to another. The next lesson makes the filtered query the only shape that compiles.

Consequence two: ON DELETE CASCADE does not fire on a soft delete. You set a foreign key to onDelete: 'cascade', ship the soft-delete action, and assume the children are handled. They aren’t. No DELETE happened — you ran an UPDATE — so the cascade is wired to an event that never occurs, doing nothing while convincing every reviewer that deletes are covered. The fix is to make the cascade explicit and atomic: soft-delete the parent and its children in the same transaction, threading tx through both updates. Skip the transaction and a mid-way error leaves a soft-deleted parent with live children: visible, half-deleted orphans.

export const invoiceLines = pgTable('invoice_lines', {
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
invoiceId: uuid()
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
});
// inside the action body — only the parent is touched:
await ctx.db
.update(invoices)
.set({ deletedAt: sql`now()` })
.where(eq(invoices.id, input.id));

The FK cascade is wired to DELETE. The soft delete is an UPDATE. The cascade never fires, so the line items are now orphaned.

Partial indexes: uniqueness and the hot path

Section titled “Partial indexes: uniqueness and the hot path”

Soft delete adds one more class of bug, and one Postgres feature fixes two faces of it: a partial index .

The correctness problem: uniqueness breaks after a soft delete. Say projects have a plain unique constraint on (organizationId, slug). A user soft-deletes a project named “Acme” and tries to create a new one, but the database refuses: the soft-deleted row still holds that slug, so the constraint still sees it. The fix is a partial unique index that applies only WHERE deleted_at IS NULL, enforcing uniqueness among live rows so the soft-deleted “Acme” is invisible to it.

Expressing this in Drizzle has two pitfalls, both of which fail silently. First, the .unique() shorthand cannot carry a WHERE clause, so you need uniqueIndex().on(...).where(...). Second, the predicate inside .where() must be a raw sql fragment: a helper like `eq(t.deletedAt, null)` emits a broken parameterized clause into the migration, and the partial index silently fails. Write it as sql${t.deletedAt} is null “ and nothing else.

The performance problem: the hot read path. The “active rows” query is the default list view, fired on nearly every page load. A partial composite index on (organization_id, created_at) WHERE deleted_at IS NULL indexes only the rows that query returns, keeping the index small and the scan tight. Pair a hot composite index with WHERE deleted_at IS NULL whenever the read path always filters deleted rows out. Skip the partial when the table is small or few rows are ever deleted, since a plain composite index covers both cases without the extra moving part. As with every tenant-scoped index, lead with organizationId.

export const projects = pgTable(
'projects',
{
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid().notNull(),
slug: text().notNull(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
...lifecycleColumns,
},
(t) => [
uniqueIndex('projects_org_slug_unique')
.on(t.organizationId, t.slug)
.where(sql`${t.deletedAt} is null`),
index('idx_projects_org_created_partial')
.on(t.organizationId, t.createdAt)
.where(sql`${t.deletedAt} is null`),
],
);

The partial unique index. WHERE deleted_at IS NULL enforces uniqueness only among live rows, so the soft-deleted “Acme” is invisible and the name can be reused.

export const projects = pgTable(
'projects',
{
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid().notNull(),
slug: text().notNull(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
...lifecycleColumns,
},
(t) => [
uniqueIndex('projects_org_slug_unique')
.on(t.organizationId, t.slug)
.where(sql`${t.deletedAt} is null`),
index('idx_projects_org_created_partial')
.on(t.organizationId, t.createdAt)
.where(sql`${t.deletedAt} is null`),
],
);

Both pitfalls in one line: uniqueIndex().where() because .unique() can’t express a partial index, and a raw sql “ predicate because eq(t.deletedAt, null) emits a broken clause.

export const projects = pgTable(
'projects',
{
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid().notNull(),
slug: text().notNull(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
...lifecycleColumns,
},
(t) => [
uniqueIndex('projects_org_slug_unique')
.on(t.organizationId, t.slug)
.where(sql`${t.deletedAt} is null`),
index('idx_projects_org_created_partial')
.on(t.organizationId, t.createdAt)
.where(sql`${t.deletedAt} is null`),
],
);

The partial composite index on the hot active-rows path: a small index and tight scan, since it covers only the rows the active query returns. Skip it when the table is small or few rows are deleted. Naming convention: explicit names, a _partial suffix for partial indexes, <table>_<cols>_unique for uniques.

1 / 1

Add a partial unique index to projects so (organization_id, slug) is unique among live rows only — a soft-deleted slug can be reused, but two live duplicates can't. Add it in the table-extension array at the bottom: uniqueIndex('projects_org_slug_unique').on(t.organizationId, t.slug).where(sql...), with the predicate written as a raw sql fragment that reads deleted_at is null. (This editor has no client-level casing, so column names are spelled out in snake_case and the PK is a plain integer — the index shape is identical to the project's.)

Two boundaries are easy to cross by accident, and crossing either is costly.

  • Directorysrc/
    • Directorydb/
      • schema.ts the lifecycleColumns helper + the entity tables + the partial unique & composite indexes
      • Directoryqueries/
        • invoices.ts next lesson: the home for the tenant-scoped read helpers that make the deletedAt IS NULL filter impossible to forget
    • Directoryapp/
      • Directoryinvoices/
        • actions.ts softDeleteInvoice, archiveInvoice, restoreInvoice
        • page.tsx the list view + the Active | Archived | All tabs, driven by ?status=

The schema declares the timestamps and indexes, actions.ts holds the three actions, and the list page reads its tab from URL state. The empty room is db/queries/invoices.ts, which the next lesson fills.

The next lesson makes reads safe, so a hand-written query can’t forget the lifecycle filter; the lesson after makes concurrent edits honest, so two tabs saving the same invoice can’t silently clobber each other.

The case against soft delete is worth knowing too, alongside the exact Postgres and Drizzle primitives that make it safe.