You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vibed/pages/.beans/pages-ytau--update-a-page-i...

4.7 KiB

title status type priority created_at updated_at
Update a page in place: re-upload content (PUT /api/pages/:slug) + upload modal completed feature normal 2026-07-14T20:15:00Z 2026-07-14T20:34:43Z

Today updating a page means delete + re-create. Add in-place content replacement.

Backend

  • core::origin::can_replace (owner-or-admin, same rule as can_delete) + tests
  • Db::update_page_stats(slug, file_count, total_bytes) (NotFound if slug missing)
  • PUT /api/pages/{slug} handler replace_page: auth → can_upload → Origin → page exists → owner/admin → size caps → content-type → build_manifest → write new R2 objects → delete stale keys under <slug>/ not in the new manifest → update D1 stats → 200
  • Route wiring with the 10 MiB body limit applied to PUT only
  • Refactor shared upload plumbing out of create_page

Frontend (static/index.html)

  • Replace the always-visible "Upload a page" card with a modal
  • "+ New Page" button opens the modal in create mode
  • "Update" button on each page row (owner/admin + can_upload) opens the modal in replace mode with the slug pre-filled and read-only
  • Modal: Esc/backdrop/close-button dismissal, focus handling

Docs

  • Design doc addendum, ARCHITECTURE, PLANNING, README (HTTP surface table)
  • USER_CONTENT_CACHE_CONTROL doc comment says "v0 has no in-place update" — update it

Summary of Changes

Backend

  • core/origin.rs: can_replace (owner-or-admin, delegates to can_delete) + tests, incl. one pinning the "same rule as delete" contract.
  • db.rs: Db::update_page_stats(slug, file_count, total_bytes) — refreshes stats only; owner_id, trusted, created_at deliberately preserved.
  • routes/api.rs: PUT /api/pages/{slug}replace_page. Auth → can_upload → Origin → page exists → owner/admin → size/type/manifest → snapshot old R2 keys → write new objects → prune stale keys (best-effort) → update D1 stats → 200. Extracted two shared helpers: manifest_from_request (size caps + Content-Type + build_manifest, shared with create_page) and list_page_keys (cursor-paginated prefix listing, shared with delete_page).
  • routes/mod.rs: PUT wired with the 10 MiB DefaultBodyLimit merged onto the PUT method only, so PATCH/DELETE keep axum's 2 MiB default.

Not transactional, by necessity: R2 has no multi-object transaction and the old bytes aren't retained, so a replace can't roll back like a create can. Ordering bounds the damage: new content is written before any old key is deleted (a viewer mid-update sees old-or-new per file, never a blank page), stale-key deletion is last and best-effort (a failure only orphans an object), and D1 stats are updated only after R2 succeeds. A mid-way put failure returns 500 with a retry message and leaves D1 untouched.

Frontend — the always-visible "Upload a page" card is gone. One modal serves both flows: "+ New Page" (create) and a per-row "Update" button (replace: slug pre-filled + read-only, filename no longer rewrites the slug). Esc / backdrop / Cancel / × all dismiss; focus returns to the opening button; success closes the modal and banners on the pages card.

Docs: design §4.6b (replace pipeline + why it can't be transactional), HTTP surface table, §5 scope cut struck through, ARCHITECTURE "Page content lifecycle", README, PLANNING.

Verification

  • Full suite green: 152 unit + 19 doctests, both-target clippy clean.
  • End-to-end against wrangler dev (local D1 + R2), session seeded directly in local D1: create 3-file zip → serves v1 → PUT single .html → serves v2, old.js/style.css now 404 (stale keys pruned), file_count 3→1, created_at/owner_login unchanged. Admin-set trusted survived an owner re-upload. Negative paths: 401 (no session), 403 (bad/missing Origin, non-owner, can_upload=0), 404 (unknown slug), 400 (bad content-type, zip without index.html), 413 (11 MiB body).
  • UI driven in jsdom with a stubbed fetch: 37/37 assertions (modal modes, correct endpoint+method per mode, slug not rewritten in replace mode, dismissal paths, no state leaking between opens).

Notes / follow-ups

  • Local-dev gotcha: wrangler dev rewrites the request Origin to the [[routes]] host, so BASE_URL=http://localhost:8787 in .dev.vars makes every mutating route 403. Use BASE_URL=http://pages.elijah.run locally. Recorded in PLANNING.md.
  • wrangler dev also drops a miniflare cache in node_modules/.mf; added to .gitignore.
  • Possible follow-ups (not done): an updated_at column + "Updated" column in the list (needs a migration); shorter Cache-Control or cache-busting so a replaced page isn't stale for up to 5 minutes.