Skip to content

File Storage

Every user-uploaded file in M2 Planner — avatars, logos, project photos, moodboard images, inspection PDFs, product images, partner certifications, CMS content — lives in one Cloudflare R2 bucket and is tracked by one Postgres table: assets (apps/api/src/db/schema.ts:6636).

This section documents the entire lifecycle end-to-end, with every claim cited to path:line in the codebase, so you can verify the system yourself rather than trust the prose.

TL;DR — why in-use files cannot be deleted

  1. Single source of truth. Only assets.refcount > 0 protects a file. There is no secondary “is it used?” signal to get out of sync with. (apps/api/src/db/schema.ts:6636-6675)
  2. Refcount is manipulated only through attachFile / detachFile. Both run inside the same DB transaction as the domain mutation, so refcount and FK can’t drift. (apps/api/src/services/files.ts:202, :219)
  3. A file only becomes deleting when refcount hits 0. Transition is atomic — a single UPDATE with a CASE expression guarantees it. (files.ts:223-231)
  4. Purge is gated by status. purgeFile refuses to delete any row not in deleting. (files.ts:288)
  5. Five independent defense layers sit on top of that (registry → empty-DB abort → prefix allowlist → 24h grace period → 500-deletion hard cap). Every one must pass before a single R2 object is deleted. (apps/api/src/queues/cleanup.ts:181-275)

For a bug to delete an in-use file, all of these would have to fail at once: the refcount update, the status CASE expression, the purge status check, and five cleanup gates. The Risk Register lists every scenario that could plausibly erode those gates and the mitigations for each.

Architecture at a glance

flowchart LR
U[Client app<br/>web / mobile / admin] -- "POST /api/upload" --> API[API Worker]
API -- "INSERT assets<br/>status=pending" --> PG[(Postgres)]
API -- "signed URL" --> U
U -- "PUT file" --> R2[(R2 bucket)]
U -- "POST /api/upload/:id/commit" --> API
API -- "UPDATE status=ready" --> PG
API -- "attachFile on domain mutation" --> PG
API -- "detachFile on delete" --> PG
CRON[Cron 02:00 UTC] --> QUEUE[CLEANUP_QUEUE]
QUEUE --> CLEAN[cleanupFiles<br/>+ cleanOrphanedFiles]
CLEAN -- "delete" --> R2
CLEAN -- "delete" --> PG

1 · Lifecycle

States (pendingreadydeleting), the functions that drive transitions, and the invariants each enforces. Read →

2 · Cleanup Safety

Five-layer defense in depth for cleanOrphanedFiles + two-phase cleanupFiles. The “can it delete an in-use file?” walkthrough. Read →

3 · Consumers

Every feature that attaches files to domain rows (user-avatar, portfolio photos, moodboard board_state, inspection PDFs, …) with cited attach/detach sites. Read →

4 · Risk Register

Eight ranked ways the guarantees could erode, with likelihood, impact, and concrete mitigations. Read →

5 · Verification

Copy-paste psql / grep / vitest commands that prove each claim on the live system right now. Read →