Files
reactbin/specs/017-short-id-migration/tasks.md
agatha 61d923d5be Feat: Replace UUID image identifiers with 8-character base62 short IDs
Short IDs become the canonical identifier in URLs (/i/:short_id),
MinIO/R2 storage keys, and all API responses. Hash-based deduplication
is preserved. Includes two-phase Alembic migration (003 adds nullable
column, 004 enforces NOT NULL) with a backfill script to copy storage
objects and populate short_id for existing images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:13:55 +00:00

17 KiB
Raw Blame History

Tasks: Short Image IDs

Input: Design documents from specs/017-short-id-migration/ Prerequisites: plan.md , spec.md , research.md , data-model.md , contracts/image-api.md , quickstart.md

Tests: Tests accompany each implementation task per §5.1. All API changes are in api/, all UI changes are in ui/src/app/.

Organization: The foundational phase (Phase 1) must complete before any user story work begins — it adds the short_id column, model field, utility function, and repository method that all three user stories depend on. US1 and US2 can then proceed; US3 (migration script) follows last because it operates on the fully wired system.

Format: [ID] [P?] [Story] Description

  • [P]: Can run in parallel (different files, no dependencies)
  • [Story]: Which user story this task belongs to

Phase 1: Foundational — Short ID Infrastructure (Blocks All User Stories)

Goal: Add the short_id column to the database, the model, a generation utility, and a repository lookup. Every user story depends on these.

Independent Test: After this phase, generate_short_id() can be called from a Python shell and returns an 8-character alphanumeric string. alembic upgrade head applies migration 003 cleanly. A manually inserted image with a short_id can be fetched by image_repo.get_by_short_id() in a test.

  • T001 Write failing unit tests for generate_short_id() in api/tests/unit/test_hashing.py: (1) returns exactly 8 characters; (2) contains only [a-zA-Z0-9] characters; (3) two consecutive calls return different values (collision test); (4) function exists and is importable from app.utils. Run make test-unit and confirm new tests FAIL.

  • T002 Add generate_short_id() to api/app/utils.py: import secrets and string; define BASE62 = string.ascii_letters + string.digits; implement def generate_short_id(length: int = 8) -> str: return ''.join(secrets.choice(BASE62) for _ in range(length)). Run make test-unit and confirm T001 tests pass.

  • T003 Create Alembic migration api/alembic/versions/003_add_short_id.py: op.add_column('images', sa.Column('short_id', sa.String(8), nullable=True)); op.create_index('ix_images_short_id', 'images', ['short_id'], unique=True). Downgrade removes index then column. Run alembic upgrade head in the api container and confirm migration applies cleanly.

  • T004 [P] Add short_id field to Image model in api/app/models.py: short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True). No change to column sizes for storage_key (keep String(64)) or thumbnail_key (keep String(70)) — values will simply be shorter after migration.

  • T005 Update api/app/repositories/image_repo.py: (a) add async def get_by_short_id(self, short_id: str) -> Image | None — SELECT with Image.short_id == short_id and selectinload(Image.image_tags).selectinload(ImageTag.tag); (b) add short_id: str parameter to create() and persist it on the Image instance. Write a unit test in api/tests/unit/ mocking the session to confirm get_by_short_id constructs the correct WHERE clause.

  • T005a Update api/tests/integration/conftest.py: wherever test fixtures call image_repo.create() or insert image rows directly, add short_id=generate_short_id() (import generate_short_id from app.utils). This ensures all integration test fixture images have a short_id value so that tests referencing image.short_id in URLs and assertions work correctly. Run make test-integration and confirm existing tests still pass (no new failures introduced).

Checkpoint: Short ID infrastructure complete. The short_id column exists in DB, generate_short_id() works, and the repo can look up images by short_id.


Phase 2: User Story 1 — Clean, Shareable Image Links (Priority: P1) 🎯 MVP

Goal: All API routes accept short_id (8-char string) instead of UUID. short_id appears in every API response. The frontend navigates to /i/:short_id and the library uses short_id for navigation.

Independent Test: Upload a new image (US2 must be done for end-to-end, but US1 can be tested using a fixture image with a known short_id inserted directly). Call GET /api/v1/images/{short_id} and confirm it returns the correct image with short_id in the response. Navigate to /i/{short_id} in the browser and confirm the detail page loads.

  • T006 Write failing unit tests in api/tests/unit/test_url_construction.py: update _make_image() mock to include short_id = 'AbCd1234'; add assertions that _image_to_dict result includes "short_id": "AbCd1234". Write failing unit tests in api/tests/unit/test_short_id.py: (1) _validate_short_id('AbCd1234') passes; (2) _validate_short_id('toolong!!') raises 422; (3) _validate_short_id('short') raises 422; (4) _validate_short_id('has space!') raises 422. Run make test-unit and confirm new tests FAIL.

  • T007 Update api/app/routers/images.py_image_to_dict: add "short_id": image.short_id to the returned dict (between "id" and "hash"). Add _validate_short_id(short_id: str) -> None helper: compile re.compile(r'^[a-zA-Z0-9]{8}$') at module level; raise HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"}) if no match. Run make test-unit and confirm T006 tests pass.

  • T008 Update all image route handlers in api/app/routers/images.py — change every image_id: uuid.UUID path parameter to short_id: str; call _validate_short_id(short_id) at the start of each handler; replace all image_repo.get_by_id(image_id) calls with image_repo.get_by_short_id(short_id). Affected routes: GET /images/{short_id}, GET /images/{short_id}/file, GET /images/{short_id}/thumbnail, PATCH /images/{short_id}/tags, DELETE /images/{short_id}. Remove import uuid if no longer used.

  • T009 [P] Write failing Angular tests: (a) in ui/src/app/services/image.service.ts — update MOCK_IMAGE in detail.component.spec.ts and any other spec files to include short_id: 'AbCd1234'; (b) in ui/src/app/library/library.component.spec.ts — add test asserting that clicking an image card calls router.navigate with ['/i', img.short_id] rather than ['/images', img.id]. Run ng test --watch=false and confirm new tests FAIL.

  • T010 Update ui/src/app/app.routes.ts: change path: 'images/:id' to path: 'i/:id'. The DetailComponent reads this.route.snapshot.paramMap.get('id') — no change needed there since the param name :id is unchanged.

  • T011 Add short_id: string to the ImageRecord interface in ui/src/app/services/image.service.ts. No changes to method signatures — get(id), updateTags(id, ...), and delete(id) already accept string; callers will now pass short_id values instead of UUIDs.

  • T011a Update ui/src/app/detail/detail.component.ts: change this.imageService.updateTags(this.image.id, updated) (×2, lines ~214 and ~224) and this.imageService.delete(this.image.id) (line ~235) to use this.image.short_id instead of this.image.id. After T008 the API accepts only 8-char short_ids; passing a UUID will trigger a 422. Add assertions to ui/src/app/detail/detail.component.spec.ts confirming that updateTags and delete are called with the short_id value ('AbCd1234') not the UUID. Run ng test --watch=false --include="src/app/detail/detail.component.spec.ts" and confirm new assertions pass.

  • T012 Update ui/src/app/library/library.component.ts: change router.navigate(['/images', img.id]) (×2: click handler and keydown handler) to router.navigate(['/i', img.short_id]). Run ng test --watch=false and confirm T009 Angular tests pass.

Checkpoint: US1 complete. API returns short_id on every image response. Routes accept short IDs. Library navigates to /i/{short_id}.


Phase 3: User Story 2 — New Uploads Assigned Short IDs (Priority: P2)

Goal: When a new image is uploaded, a short ID is generated, used as the storage key (replacing the hash), and returned in the response. Deduplication by content hash still works.

Independent Test: Upload a new image. Confirm the response includes a short_id field with exactly 8 alphanumeric characters. Confirm storage_key equals short_id and thumbnail_key equals {short_id}-thumb. Upload the same image again — confirm duplicate: true and the same short_id is returned.

  • T013 Write failing integration tests in api/tests/integration/test_upload.py: (1) upload a new image → response includes short_id field matching [a-zA-Z0-9]{8}; (2) storage_key in response equals short_id; (3) thumbnail_key in response equals {short_id}-thumb (or is null for images without thumbnails); (4) upload same file twice → second response has duplicate: true and identical short_id. Run make test-integration and confirm new tests FAIL.

  • T014 Update the upload handler in api/app/routers/images.py: after the hash duplicate check, add collision-retry loop (up to 10 attempts): short_id = generate_short_id(); call await storage.put(short_id, data, mime_type) instead of await storage.put(hash_hex, ...); call await storage.put(f"{short_id}-thumb", ...) instead of f"{hash_hex}-thumb"; pass storage_key=short_id, thumbnail_key=f"{short_id}-thumb" (or None), and short_id=short_id to image_repo.create(). Catch IntegrityError on create(), rollback, retry with new short_id. Import generate_short_id from app.utils and IntegrityError from sqlalchemy.exc. Run make test-integration and confirm T013 tests pass.

  • T015 Update ui/src/app/upload/upload.component.ts: change this.router.navigate(['/images', res.id]) to this.router.navigate(['/i', res.short_id]). Add a test to the upload component spec (or update the existing navigation test) asserting the route uses short_id.

Checkpoint: US2 complete. All new uploads produce a short ID and are immediately accessible at /i/{short_id}.


Phase 4: User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)

Goal: A runnable script backfills short_id for all pre-existing images, copies their storage objects to the new key pattern, and updates DB records. A final Alembic migration adds the NOT NULL constraint. After this phase the system has no UUID-keyed storage objects.

Independent Test: Run the migration script — confirm it prints a count of migrated images and exits cleanly. Run it a second time — confirm it reports 0 migrated (idempotent). Browse the library and open pre-migration images — confirm all load with short ID URLs and no broken images.

  • T016 Write unit tests in api/tests/unit/test_migration.py covering the migration script logic: (1) an image with short_id IS NULL is processed (short_id generated, storage copy called, DB update called, old keys deleted); (2) an image with short_id already set is skipped; (3) if a storage copy fails, the error is logged and the script continues to the next image (no abort); (4) the summary at the end reports correct migrated and skipped counts. Mock the storage client and DB session. Run make test-unit and confirm new tests FAIL (script not yet created).

  • T017 Create api/scripts/__init__.py (empty) and api/scripts/migrate_to_short_ids.py: async main function that (a) reads DB URL and storage config from env vars via app.config.get_settings(); (b) creates an async DB session and S3StorageBackend instance; (c) queries all images where short_id IS NULL; (d) for each: generate short_id (retry on UniqueViolation), copy storage object using data = await storage.get(old_key); await storage.put(new_key, data, image.mime_type) (the StorageBackend interface provides only get/put/delete — there is no copy method), verify the copy succeeded by calling await storage.get(new_key) and catching any exception, update the DB row (short_id, storage_key, thumbnail_key), then delete old keys with await storage.delete(old_key); (e) skips images where thumbnail_key IS NULL for the thumbnail copy step; (f) wraps each image in a try/except so a single failure logs an error and continues to the next image; (g) prints Migrated: N, Skipped: M, Failed: K on completion. Entry point: if __name__ == '__main__': asyncio.run(main()). Run make test-unit and confirm T016 tests pass.

  • T018 Create Alembic migration api/alembic/versions/004_short_id_not_null.py: op.alter_column('images', 'short_id', nullable=False). Run this migration only after the migration script completes with 0 remaining NULL rows. Downgrade sets nullable=True. Document this ordering requirement in the migration file's docstring.

Checkpoint: US3 complete. All existing images have short IDs, storage objects use new key pattern, short_id column is NOT NULL.


Phase 5: Polish & Cross-Cutting Concerns

  • T019 Update api/tests/integration/test_search.py, test_delete.py, test_serving.py, test_tags.py, and test_public_access.py: wherever tests construct a URL with f"/api/v1/images/{image.id}" or f"/api/v1/images/{uuid}", replace with f"/api/v1/images/{image.short_id}". Ensure test fixtures (conftest.py) populate short_id on images created for testing. Run make test-integration and confirm all integration tests pass.

  • T020 Update ui/src/app/detail/detail.component.spec.ts: add short_id: 'AbCd1234' to MOCK_IMAGE and MOCK_IMAGE_ABS constants. Update any test assertions that check navigation targets to use short_id. Run ng test --watch=false --include="src/app/detail/detail.component.spec.ts" and confirm all tests pass.

  • T021 Run ng lint across all modified UI files and ruff check api/app/ api/tests/ api/scripts/ across all modified API files; fix any issues. Confirm ruff format --check api/ passes.

  • T022 Run ng build --configuration production and confirm build succeeds with no TypeScript errors. Run make test-unit && make test-integration and confirm all tests pass.

  • T023 Manually verify all seven quickstart.md scenarios in the browser: (1) new upload navigates to /i/{short_id}; (2) deduplication returns same short_id; (3) library cards navigate to /i/; (4) tag and delete work via short_id; (5) pre-migration images accessible (after running script); (6) migration is idempotent; (7) "Copy URL" copies the CDN URL with short_id.


Dependencies & Execution Order

  • T001 before T002 (write failing tests before implementation)
  • T002 before T003/T004/T005 (utility must exist before migration and model reference it)
  • T003 before T004 (DB column must exist before model references it)
  • T004 before T005 (model must have short_id field before repo uses it)
  • T005 before T005a (conftest fixtures need the updated create() signature)
  • T005a before T006 (integration test fixtures must have short_id before any integration tests run)
  • T006 before T007 (write failing tests before implementation)
  • T007 before T008 (helper and dict update before route param changes)
  • T008 before T009/T010/T011 (API must accept short_id before frontend uses it)
  • T009 before T010/T011/T011a/T012 (write failing tests before implementation)
  • T010, T011, T011a, T012 can run in parallel (different files)
  • T011 before T011a (interface must have short_id field before detail component uses it)
  • T013 before T014 (write failing tests before upload changes)
  • T014 before T015 (upload must produce short_id before frontend navigation uses it)
  • T016 before T017 (write failing tests before script)
  • T017 before T018 (script must run successfully before NOT NULL migration)
  • T019T023 after T018 (polish after all implementation complete)

Execution Order Summary

Step 1: T001 → T002         (generate_short_id: tests → implementation)
Step 2: T003, T004 (parallel) (Alembic 003 + model update)
Step 3: T005                        (repo: get_by_short_id + create update)
Step 3a: T005a                      (conftest: fixture images get short_id)
Step 4: T006 → T007 → T008         (API routes: tests → dict → route params)
Step 5: T009                        (Angular failing tests)
Step 6: T010, T011, T011a, T012 (parallel) (route, interface, detail caller fix, library navigation)
Step 7: T013 → T014 → T015  (upload: tests → handler → upload navigation)
Step 8: T016 → T017 → T018  (migration: tests → script → NOT NULL migration)
Step 9: T019, T020 (parallel) (test updates)
Step 10: T021, T022, T023   (lint, build, manual verification)

Implementation Strategy

MVP (US1 + US2 — full feature with new uploads)

  1. T001T005a — foundational infrastructure + conftest fixtures
  2. T006T012 (including T011a) — API routes accept short_id + frontend uses /i/
  3. T013T015 — new uploads generate short_id
  4. STOP and VALIDATE: upload a new image, confirm /i/{short_id} URL, confirm browsing works
  5. T016T018 — migrate existing images
  6. T019T023 — polish

Note on Priority vs Implementation Order

US1 (P1) and US2 (P2) are implemented together before US3 (P3). The foundational phase is the true prerequisite for all three. US1 and US2 are tightly coupled in practice (you need uploads to produce short IDs before routing can be tested end-to-end), so they are sequenced rather than strictly priority-ordered.