Skip to content

Write Read Delete Flows

Flows are where schema decisions get tested — every column and index must serve at least one flow.

If a flow is slow or broken, trace it back to the schema.


Write Flow — Creating a Paste

1. Client sends POST /api/v1/pastes with content + expiryDays + optional customAlias
   JWT in header identifies the user → extract user_id

2. App server hashes the content:
   content_hash = SHA-256(paste_text)

3. Check if content already exists:
   SELECT 1 FROM content WHERE content_hash = ?

   → EXISTS:
     UPDATE content SET ref_count = ref_count + 1 WHERE content_hash = ?
     (content already in S3, skip upload)

   → NOT EXISTS:
     INSERT INTO content (content_hash, s3_url, ref_count) 
     VALUES (?, 'PENDING', 1)
     Upload paste_text to S3 async → on completion: UPDATE content SET s3_url = ? 
     ⚠ Risk: s3_url is null/PENDING during upload window (covered below)

4. Generate short_code:
   → customAlias provided: check it's not taken in pastes table, use it
   → no alias: generate random 8-char Base62 code

5. INSERT INTO pastes (short_code, user_id, content_hash, expires_at, created_at)
   expires_at = NOW() + INTERVAL '? days'  (1, 7, or 30)

6. Return short_code to client → 200 OK

The async S3 upload window

Between step 3 (content row inserted with s3_url = 'PENDING') and S3 upload completing, a read for that paste will find the content row but get a null/PENDING s3_url. The app server must handle this case — either return a 503 "content not ready yet", or make the S3 upload synchronous before returning the short_code. Synchronous upload adds latency to writes (~100-200ms for a 10KB file) but eliminates the inconsistency window. Given our write SLO is p99 < 100ms, this is worth flagging as a deep dive decision.


Read Flow — Viewing a Paste

1. Client sends GET /api/v1/pastes/:shortCode (no auth required)

2. App server queries pastes table:
   SELECT p.short_code, p.content_hash, p.expires_at, p.deleted_at, c.s3_url
   FROM pastes p
   JOIN content c ON p.content_hash = c.content_hash
   WHERE p.short_code = ?

3. Check validity:
   → Row not found       → 404
   → deleted_at IS SET   → 404 (treat deleted same as not found)
   → expires_at < NOW()  → 404 (expired — treat same as not found)

4. Fetch paste text from S3 using c.s3_url

5. Return to client:
   { data: { content: text, expiresAt: expires_at }, error: null }

Why merge 404 cases? Returning different error codes for "not found", "deleted", and "expired" leaks information — an attacker could enumerate which short codes ever existed and infer when pastes were created or deleted. A single 404 for all three cases reveals nothing.

Read-your-own-writes: The creator may read their paste immediately after creation — before read replicas have synced. To guarantee they see their paste, route their first read (within a short window after creation, identified by JWT) to the primary DB. By the time they share the link externally, replicas have caught up.


Delete Flow — Removing a Paste

1. Client sends DELETE /api/v1/pastes/:shortCode (auth required)
   JWT identifies the caller → extract caller_user_id

2. Look up the paste:
   SELECT user_id, content_hash FROM pastes 
   WHERE short_code = ? AND deleted_at IS NULL

   → Row not found or already deleted → return 204 immediately (idempotent)

3. Authorisation check:
   IF paste.user_id != caller_user_id → return 403 Forbidden

4. Execute in a single transaction:
   BEGIN;
     UPDATE pastes SET deleted_at = NOW() WHERE short_code = ?;
     UPDATE content SET ref_count = ref_count - 1 WHERE content_hash = ?;
     -- check if ref_count hit zero
     SELECT ref_count FROM content WHERE content_hash = ?;
   COMMIT;

5. If ref_count = 0:
   DELETE FROM content WHERE content_hash = ?
   Schedule S3 blob deletion (async, background job)

6. Return 204 No Content

Why soft delete instead of hard delete? Soft delete (setting deleted_at) is instant — a single column update. Hard delete involves cascading checks, potential FK constraint violations, and makes the operation non-reversible. Soft delete also preserves audit history. A background cleanup job handles physical row removal later.

Why the transaction wraps UPDATE pastes + UPDATE content? If the ref_count decrement succeeds but the pastes soft-delete fails (or vice versa), the DB is in an inconsistent state — ref_count doesn't match the actual number of active pastes rows. The transaction guarantees both happen or neither does.


Expiry Cleanup — Background Job

A nightly job scans for pastes that have expired or been soft-deleted:

SELECT short_code, content_hash 
FROM pastes 
WHERE expires_at < NOW() OR deleted_at IS NOT NULL;

For each result: 1. Decrement ref_count on the content row 2. If ref_count = 0: delete content row + schedule S3 blob deletion 3. Hard-delete the paste row (or archive to cold storage)

This job runs on the expires_at index — fast range scan, not a full table scan.


Interview framing

"Write: hash content → check if exists → increment ref_count or insert new → generate short_code → insert paste row → return short_code. Read: lookup by short_code → check not deleted/expired → fetch from S3 → return. Delete: verify ownership → soft delete paste row + decrement ref_count in one transaction → if ref_count=0 schedule S3 cleanup. All 404 cases (not found / deleted / expired) return the same error to avoid leaking existence information."