---
name: tmpstate-db-design
description: Use this skill when designing or implementing an app backed by a TmpState database — data modeling, quota-aware reads/writes, response shapes, pagination, caching and loading states, polling, expiry handling, and keeping TmpState invisible infrastructure.
---

# TmpState Database Design Skill

TmpState is a tokenless temporary JSON document store. One URL is the whole
credential; collections are created implicitly on first write; documents are
schemaless JSON. Full API reference: https://tmpstate.dev/llms.txt

The golden rule:

> The db URL stays on the server, writes are a budget, reads are free but
> rate-limited, and the user should never see TmpState — only your product.

## Architecture — non-negotiable

DO:
- Keep the TmpState db URL server-side only (env file or server-only config).
- Expose app-specific route handlers (/api/tasks, /api/cart) and have the
  client call only those. Your API is the contract; TmpState is the backend.
- Store the admin URL and expires_at in developer handoff notes/README, never
  in product UI.
- Use SWR/React Query (or similar) between your client and YOUR routes.

DO NOT:
- Do not ship the capability URL in client-side code, public repos, or logs.
  Anyone with the URL can read and write everything.
- Do not surface TmpState branding, expiry banners, admin links, or lifecycle
  controls inside normal app UI.
- Do not build the client directly against $DB endpoints for product-like
  apps (fine for one-file browser prototypes only).

## Data modeling for a document store

There are no joins, no indexes, no transactions across documents.

DO:
- Model one document per thing the UI shows as a unit (a task, a cart, a
  comment). Denormalize per view: duplicating a userName into a comment doc
  beats a second lookup per row.
- Keep documents small: max 10,240 bytes serialized (Pro: 65,536).
  Long lists belong in a collection of docs, not one giant array doc.
- Design for shallow PATCH: top-level keys merge, but a nested object is
  replaced wholesale. Prefer flat-ish documents ({"status":"done"} not
  {"state":{"status":"done","history":[...]}}) so partial updates stay cheap
  and safe.
- Let the server own ids (doc_...) and timestamps (created_at/updated_at);
  never invent your own primary keys.
- Store counts/aggregates you need often in their own summary doc updated on
  write, instead of listing everything to count it.

DO NOT:
- Do not exceed collection name rules: ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$, no __
  prefix, not a reserved word (admin, new, health, db, api).
- Do not use keys __proto__, constructor, or prototype at any depth (rejected).
- Do not build one mega-document that every write must rewrite — it burns
  write quota and races against concurrent PATCHes.

## Writes are a budget

A free database has 100 writes and 100 docs (Pro: 100,000 writes,
10,000 docs). Inserts and PATCHes count; reads and deletes never do — a
full database can always be emptied.

DO:
- Debounce user input. One PATCH when the user stops typing, not one per
  keystroke.
- Batch related changes into one document update where the model allows.
- Keep seed/sample data tiny — it spends the same budget as real data.
- Check remaining budget with GET $DB/__meta from dev tooling when the app
  writes a lot.

DO NOT:
- Do not write on a timer or on every render.
- Do not "touch" documents to bump updated_at.
- Do not retry failed writes in a tight loop; on 429 quota_exceeded stop and
  tell the developer — deletes still work, writes are gone.

## The two response-shape bugs everyone hits

- A single document wraps YOUR fields under "data": doc.data.title, not
  doc.title.
- A collection list wraps documents under "items", NOT "data": res.items.
  A missing or empty collection returns 200 with "items": [], never 404.

Unwrap once in your server route and hand the client clean app objects.

## Pagination

- Cursor-based: ?limit=1..100 (default 50), then follow next_cursor until null.
- Cache pages by (collection, filters, cursor) in your client cache.
- Keep loaded rows visible while fetching the next page; append, don't jump.

## Reads, caching, and loading states

Reads are free but rate-limited per client IP (~600/min for data routes,
60/min for __export and __schema). A burst returns 429 rate_limited —
slow down and retry with backoff.

DO:
- Render the app shell immediately; skeleton only the data regions, matching
  the final layout.
- Keep previous data visible during background refresh ("Updated 12s ago"),
  never replace visible rows with a spinner.
- Treat initial load, background refresh, and mutation as distinct states.
- Distinguish empty ("items": []) from loading from error — three different
  UIs.
- Poll politely when the app needs freshness: seconds-scale intervals on the
  visible view only. There are no websockets; polling your own API route is
  the realtime story.
- Use optimistic updates for safe, reversible actions (toggle, rename,
  reorder) with snapshot -> rollback on failure -> reconcile with the server
  document the API returns.

DO NOT:
- Do not poll __export or __schema — they scan the whole database and hit the
  tight rate limit. They are for handoff and debugging.
- Do not refetch every collection after one mutation; the API returns the
  full updated document — use it as canonical truth and invalidate precisely.
- Do not use optimistic UI for anything involving payment, deletion of real
  user data, or quota-critical writes.
- Do not show "Saved" before the write actually returned 200/201.

## Expiry and lifecycle — never silently replace a database

Free databases expire after 24 hours; every request then returns 410 and the
data stays frozen and restorable for 72 hours.

DO:
- Check liveness from dev tooling with GET $DB/__health (200 alive, 404/410
  gone/expired) and expiry with GET $DB/__meta.
- On 410, stop and tell the user outside the app: the database expired but is
  restorable — a paid extension (from $1) restores everything, or start
  fresh deliberately. The 410 details include the __extend URL.
- Reuse an existing database (README, .env, handoff notes, your memory)
  before creating a new one.

DO NOT:
- Do not build clients that quietly create a fresh database on 410 and
  reinitialize — user data would not carry over, and hiding that is worse
  than reporting it.
- Do not put expiry countdowns or extension upsells in the product UI; keep
  lifecycle in the agent/developer conversation.

## Error handling

Every error is JSON: {"error":{"code":"...","message":"...","details":{...}}}.
Handle at minimum in your server routes:

- 404 db_not_found / not_found -> surface as your own 404, not TmpState's
- 410 expired               -> lifecycle conversation (above), never auto-recreate
- 413 payload_too_large     -> shrink the document, don't retry as-is
- 429 quota_exceeded        -> writes are spent; stop writing, inform the developer
- 429 rate_limited          -> back off and retry; slow your polling
- 5xx                       -> retry with backoff, keep showing cached data

Never leak raw TmpState errors (or the capability URL inside them) to the
browser.

## Pre-ship checklist

- [ ] db URL lives server-side only; client talks to app routes
- [ ] documents unwrap data/items exactly once, in one place
- [ ] every user-visible list paginates via cursor
- [ ] writes are debounced/batched; no write-per-keystroke or timer writes
- [ ] loading, empty, and error states are three distinct UIs
- [ ] background refresh keeps old data visible
- [ ] optimistic updates have snapshot + rollback + server reconciliation
- [ ] 410 is handled as "expired, restorable", never auto-recreate
- [ ] at least one real read/write path verified end to end
- [ ] handoff notes include admin URL, expires_at, and where the db URL lives
