Skip to content

feat: per-verse audio recordings (upload/serve for mobile) - #224

Merged
henrique221 merged 20 commits into
mainfrom
feat/verse-audio
Aug 6, 2026
Merged

feat: per-verse audio recordings (upload/serve for mobile)#224
henrique221 merged 20 commits into
mainfrom
feat/verse-audio

Conversation

@henrique221

@henrique221 henrique221 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Backend support for per-verse translator audio recordings, built for the Fluent mobile app: upload through the API into Cloudflare R2, metadata in Postgres, playback via short-lived presigned URLs.

Endpoint Auth gate Behavior
PUT /verse-audio/{projectUnitId}/{bibleTextId} CONTENT_UPDATE + ChapterAssignmentPolicy.edit (same gate as editing the verse text) Multipart file (+ optional durationSeconds); create-or-replace; returns metadata + downloadUrl
GET /verse-audio/{projectUnitId}/{bibleTextId} PROJECT_VIEW + project membership Metadata + 15-min read-only SAS downloadUrl
GET /verse-audio?projectUnitId=&bookId=&chapterNumber= same as GET All recordings for a chapter, verse-ordered — one call for chapter playback
DELETE /verse-audio/{projectUnitId}/{bibleTextId} same as PUT Removes blob + row

Design decisions (spec'd before implementation):

  • One recording per verseverse_audio_recordings unique on (project_unit_id, bible_text_id); re-upload replaces in place. Blob names are deterministic (unit-{id}/text-{id}), so replacement never orphans a blob.
  • Upload via API, playback via SAS — 30 MB cap (bodyLimit), 8-type audio MIME allowlist, blob-first-then-row write ordering (drift windows documented in code); playback streams directly from storage, no audio bytes proxied through the API.
  • Auth mirrors translated-verses exactly — forbidden access masked as 404 per repo convention; IDs travel in path/query only, never the multipart body; all routes declare 401/403 (and reachable 400s) in their OpenAPI response maps. Mobile BetterAuth sessions work unchanged.

Screenshots

1) New "Verse Audio" group in the API reference (/reference)

All four endpoints registered in Scalar: upload (PUT), get (GET), delete (DEL), and the chapter listing (GET /verse-audio).

01-reference-verse-audio-section

2) Full PUT upload contract

Path params, multipart body (file + optional durationSeconds), and every declared response — 200 with downloadUrl, 400/401/403/404, 413 (30 MB cap), 500, 503 (storage unconfigured).

02-reference-put-upload-detail

3) Chapter-listing endpoint contract

Query params (projectUnitId, bookId, chapterNumber) and the verse-ordered items response the mobile app uses for one-call chapter playback.

03-reference-chapter-list-detail

4) SAS URL streaming in the browser's native player

The downloadUrl from a real upload, opened directly: Chrome's built-in audio player streaming the 3-second recording (shown mid-playback at 0:01/0:03) straight from Azurite — no bytes proxied through the API.

04-sas-url-native-playback

Config & deploy

  • R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY (new, optional) — when any is unset the audio routes return 503 {message} and nothing else is affected.
  • R2_JURISDICTION (default eu) — pins the endpoint to {account}.eu.r2.cloudflarestorage.com so data at rest stays in the EU. Creating the bucket in that jurisdiction is an infra step code cannot do (dashboard/IaC); documented in .env.example.
  • R2_AUDIO_BUCKET (default verse-audio) — deliberately not the exports bucket, whose contents are TTL-swept. Recordings are permanent.
  • AUDIO_RECLAIM_INTERVAL_MS (default 1h) — how often orphaned objects are reclaimed.
  • Local dev: Azurite + the well-known dev connection string (documented in .env.example).
  • Migration 0015_add_verse_audio_recordings — additive only, no backfill, trivially reversible.

Related work

  • feat(usfm): move async export to Azure Blob with SAS downloads and reliable jobs #212 / feat/196-async-export-hardening — the async-export storage work, also now on R2. Still untouched by this branch: audio keeps its own src/lib/audio-storage.ts (distinct filename ⇒ clean merges in either order) because the two have different buckets and lifecycles. Consolidating a shared R2 module is a follow-up once both land.
  • Audio sync with cloudfare #188 (ft/audio-record-sync) — a parallel approach to recording sync (POST /recordings/sync, .m4a → Cloudflare R2, standalone recordings table; its 0012/0013 migrations now conflict with main's journal). This PR differs deliberately: verse-anchored data model with a per-verse uniqueness invariant, chapter-assignment policy gating, full CRUD + chapter listing, and tracked storage objects with orphan reclaim. Both are now on R2, so the remaining question is which implementation wins — happy to help reconcile either way.

Storage objects and orphan reclaim

Postgres cannot delete an object in a bucket, so cascades alone can never keep storage clean: dropping a project unit cascades its recordings away and would strand their audio. Following @kaseywright's call on the migration thread (a visible table beats a DB trigger, which rots without a dedicated DBA):

  • storage_objects (bucket, key, created_at, deleted_at) records every object written.
  • verse_audio_recordings.storage_object_id references it without cascading, so when the recording row disappears the storage row survives as the marker.
  • A periodic sweep deletes objects nothing references any more and stamps deleted_at. It never starts when R2 is unconfigured, and a failed delete leaves the row unstamped so the next pass retries.
  • The storage row is claimed before the object is written, so a crash between the two leaves a reclaimable marker rather than untracked bytes.

Tooling riders

  • eslint.config.mjs + .prettierignore now skip the gitignored .superpowers/ scratch dir (both tools walk the disk regardless of gitignore).
  • doc.route.test.ts per-test timeout 5s → 20s, with an explanatory comment: its whole-app import('@/app') pays every route module's transform cost under parallel collect, and the graph now includes the AWS S3 SDK (verified against an origin/main baseline before touching the test).

Out of scope (per design) / follow-ups

  • Transcoding/waveforms, multiple takes, web recording UI, Aquifer resource-audio proxying, direct-to-blob uploads.
  • Blob sweep on project-unit cascade deletedone: see storage objects below.
  • MIME allowlist is exact-match: parameterized types like audio/webm;codecs=opus (web MediaRecorder) are rejected — normalize before comparing if/when web recording lands.
  • Route-level test for the new multipart/bodyLimit/503 mechanics (service layer is covered; sibling domains have no route tests either).
  • Lazy-load the S3 SDK so an unconfigured deployment doesn't pay its import cost at boot.
  • Consolidate audio-storage.ts and blob-storage.ts onto one R2 module once feat(usfm): move async export to Azure Blob with SAS downloads and reliable jobs #212 lands.

Testing

  • verse-audio.service.test.ts — 12 tests: MIME/empty-file validation, blob-then-row ordering and failure paths, playback-URL attachment, delete ordering (blob before row).
  • audio-storage.test.ts — blob naming, unconfigured behavior.
  • Full suite 20 files / 183 tests green; npm run precheck green; CI validate ✅.
  • Live smoke (local Postgres + Azurite, seeded project + translator): mobile-header sign-in (set-auth-token) → three real mp3 uploads (200 each) → chapter listing returns all three verse-ordered with sizes → downloadUrl streams audio/mpeg in Chrome's native player (screenshot 4) → video/mp4 upload correctly rejected 400. Local-dev note: Azurite must run with --skipApiVersionCheck for SDK ≥ 12.33.

Summary by CodeRabbit

  • New Features
    • Added verse audio APIs for uploading, retrieving, listing by chapter, and deleting recordings with permission checks.
    • Added Cloudflare R2 storage with playback/download links and automated orphan cleanup.
    • Added tracking for audio recordings and stored files.
  • Bug Fixes
    • Improved OpenAPI documentation test stability in slower environments.
  • Chores
    • Updated environment, storage, and local tooling configurations for R2 support.

@henrique221 henrique221 self-assigned this Jul 15, 2026
@henrique221
henrique221 requested a review from Copilot July 15, 2026 18:37
@henrique221

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new verse-audio domain to support per-verse translator audio recordings for the Fluent mobile app, including upload/replace, playback via short-lived SAS URLs, and deletion, backed by Azure Blob Storage and a new database table.

Changes:

  • Introduces verse-audio routes + auth middleware and service/repository layers for upload/get/list/delete flows.
  • Adds Azure Blob storage integration (src/lib/audio-storage.ts) and a DB table + migration (verse_audio_recordings) with a per-(projectUnitId,bibleTextId) uniqueness constraint.
  • Updates tooling/test harness (doc route test timeout; eslint/prettier ignores; env example + env schema) and adds Azure SDK dependency.

Reviewed changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/routes/doc.route.test.ts Increases /doc OpenAPI test timeout to reduce flakiness from larger import graph.
src/lib/types.ts Adds error codes/messages/statuses for verse-audio and upload validation.
src/lib/audio-storage.ts Implements Azure Blob upload/delete and 15-minute read-only SAS URL generation for verse audio.
src/lib/audio-storage.test.ts Adds unit tests for blob naming and unconfigured-storage behavior.
src/env.ts Adds optional AZURE_STORAGE_CONNECTION_STRING and AUDIO_CONTAINER env vars.
src/domains/verse-audio/verse-audio.types.ts Defines limits, content-type allowlist, domain types, and OpenAPI schemas.
src/domains/verse-audio/verse-audio.service.ts Implements upload/get/list/delete orchestration (blob + DB) and URL attachment.
src/domains/verse-audio/verse-audio.service.test.ts Adds service-level tests with storage/repo mocked.
src/domains/verse-audio/verse-audio.route.ts Adds PUT/GET/DELETE /verse-audio/{projectUnitId}/{bibleTextId} and chapter listing GET /verse-audio.
src/domains/verse-audio/verse-audio.repository.ts Adds Drizzle queries for get/list/upsert/remove of verse-audio metadata.
src/domains/verse-audio/verse-audio-auth.middleware.ts Enforces project membership / chapter assignment policy and masks forbidden as 404.
src/db/schema.ts Adds verse_audio_recordings table definition to Drizzle schema.
src/db/migrations/0015_add_verse_audio_recordings.sql Creates the verse_audio_recordings table and unique index.
src/db/migrations/meta/0015_snapshot.json Drizzle migration snapshot for the new table.
src/db/migrations/meta/_journal.json Registers migration 0015_add_verse_audio_recordings.
src/app.ts Registers the new verse-audio routes into the app.
package.json Adds @azure/storage-blob dependency.
package-lock.json Locks Azure SDK dependency graph.
eslint.config.mjs Ignores .superpowers/** during linting.
.prettierignore Ignores .superpowers during formatting.
.env.example Documents new env vars for verse-audio storage configuration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/audio-storage.ts
mattrace-gloo
mattrace-gloo previously approved these changes Jul 20, 2026

@kaseywright kaseywright left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decision has been made to use CloudFlare R2 as cloud storage for the project. We should ensure that the GDPR jurisdiction is set so that the files at rest in the R2 buckets are located in known regions in Europe to start with. Some of these settings might be out of scope for this PR while integrating CloudFlare.

Comment thread src/domains/verse-audio/verse-audio.route.ts Outdated
Comment thread .gitignore
Comment on lines +13 to +16
ALTER TABLE "verse_audio_recordings" ADD CONSTRAINT "verse_audio_recordings_project_unit_id_project_units_id_fk" FOREIGN KEY ("project_unit_id") REFERENCES "public"."project_units"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "verse_audio_recordings" ADD CONSTRAINT "verse_audio_recordings_bible_text_id_bible_texts_id_fk" FOREIGN KEY ("bible_text_id") REFERENCES "public"."bible_texts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "verse_audio_recordings" ADD CONSTRAINT "verse_audio_recordings_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "uq_verse_audio_per_bible_text" ON "verse_audio_recordings" USING btree ("project_unit_id","bible_text_id"); No newline at end of file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

project_unit_id cascades on delete (good), but bible_text_id has ON DELETE no action. Deleting a bible_texts row that has recordings will fail with a FK violation. If that's intentional (preserve recordings), the Azure blob is still orphaned since the DB row blocks cleanup. If verses can be deleted, consider ON DELETE cascade for bible_text_id too — but note the blob won't be cleaned up automatically either way (no DB-triggered blob deletion). Worth documenting the intended lifecycle.

Typically, bible verses will not be deleted but we should implement a mechanism to avoid orphaning recordings. I am open to a separate table to track recordings and their cloud storage locations - this can be modifying a current table or a new one. Whichever makes the most sense.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, and to state the current lifecycle plainly: bible_texts rows aren't deleted in practice so the no-action FK is really just a guard, but deleting a project_unit does cascade the rows away and leaves the blobs sitting under unit-{id}/, so today storage can definitely orphan. cascades alone can't fix that since nothing in postgres deletes a blob, so yeah, a tracking mechanism is the right call rather than leaning on FK behavior. i'd rather land it together with the azure to r2 migration this PR needs anyway per your other review, so that whatever we track records the r2 bucket and key from the start instead of azure containers we're about to drop. two directions i see: a storage_objects table (bucket, key, created_at, deleted_at) that verse_audio_recordings references and deliberately does not cascade, so a surviving row is the marker a sweeper uses to reclaim the blob, or a delete-tombstone queue written by a db trigger, which has the advantage of firing on cascades too, drained by a worker. which would you rather have? happy to build either, just want your call before i wire it up.

@kaseywright kaseywright Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A storage_objects table is probably the best way here. Although I like DBMS features, triggers usually get lost because they are not visible in the application code. Without a dedicated DB admin, it's best to stick with highly visible changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that settles it, and agreed on the reasoning, a trigger that fires on cascade is great right up until someone greps the app code for what deletes blobs and finds nothing, which with no dedicated dba is exactly how it rots. so it'll be a storage_objects table holding bucket, key, created_at, deleted_at, referenced by verse_audio_recordings and deliberately not cascading, so when a recording row goes away the storage row survives as the marker a reclaim sweeper picks up. it lands together with the azure to r2 swap this PR still needs, so the very first rows written record r2 bucket and key instead of azure containers we're about to drop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

built it, 3a9013f.

storage_objects is in (bucket, key, created_at, deleted_at) with verse_audio_recordings referencing it and deliberately no cascade, so when a project unit gets dropped the recording rows go but the storage rows stay behind as the markers. a sweep on an interval deletes those objects and stamps deleted_at, and if r2 is unconfigured it doesn't even start. one detail i went with: the storage row is claimed before the object is written, so a crash between the two leaves a reclaimable marker rather than bytes nothing knows about.

same commit moves this branch off azure blob onto r2, eu jurisdiction endpoint, presigned playback urls still 15 min. env vars now match the export pr (R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_JURISDICTION), with a separate R2_AUDIO_BUCKET since exports get ttl-swept and recordings shouldn't share a bucket with that. creating the bucket in the eu jurisdiction is still the one bit code can't do, it's in .env.example as the infra step.

193 tests green.

Every other error response on these routes returns { message } via
createMessageObjectSchema; the storage-unavailable 503 returned
{ error, details }, so clients had to handle two error shapes from the
same four routes. The shape was inherited from usfm.route.ts, which uses
{ error, details } for all of its errors and is internally consistent —
verse-audio was not. Status and "storage not configured" semantics are
unchanged; only the body shape and its OpenAPI schema move.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@henrique221, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 35417bcf-56e4-41c0-ac82-64e14ff69208

📥 Commits

Reviewing files that changed from the base of the PR and between 139097d and d6f3190.

📒 Files selected for processing (8)
  • .env.example
  • src/domains/verse-audio/verse-audio.route.ts
  • src/env.test.ts
  • src/env.ts
  • src/index.ts
  • src/lib/audio-storage.test.ts
  • src/lib/audio-storage.ts
  • src/lib/blob-storage.ts
📝 Walkthrough

Walkthrough

Replaces Azure Blob Storage with Cloudflare R2 for verse audio. Adds storage-object persistence, orphan reclamation, authorized audio routes, updated audio contracts and errors, and startup cleanup scheduling.

Changes

Verse audio

Layer / File(s) Summary
R2 storage and audio contracts
.env.example, src/env.ts, src/lib/audio-storage.ts, src/domains/verse-audio/verse-audio.types.ts, src/lib/types.ts, package.json, src/lib/audio-storage.test.ts
Adds R2 configuration, S3 operations, presigned URLs, audio contracts, error mappings, AWS SDK packages, and updated storage tests.
Recording and storage-object persistence
src/db/schema.ts, src/db/migrations/*, src/domains/verse-audio/storage-objects.repository.ts, src/domains/verse-audio/verse-audio.repository.ts
Adds storage-object tracking, links recordings to storage objects, records migration metadata, and updates repository queries and upserts.
Audio lifecycle and reclamation
src/domains/verse-audio/verse-audio.service.ts, src/domains/verse-audio/verse-audio.service.test.ts
Coordinates storage claims, uploads, asynchronous URLs, deletion stamping, orphan cleanup, and service tests.
Authorized verse audio API
src/domains/verse-audio/verse-audio-auth.middleware.ts, src/domains/verse-audio/verse-audio.route.ts, src/app.ts
Registers authenticated PUT, GET, listing, and DELETE routes with project and assignment authorization.
Background reclamation and repository tooling
src/index.ts, .gitignore, .prettierignore, eslint.config.mjs, vitest.config.ts, src/routes/doc.route.test.ts
Schedules configured audio cleanup, stops it during shutdown, excludes local paths, and extends the OpenAPI test timeout.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant VerseAudioRoutes
  participant AccessMiddleware
  participant VerseAudioService
  participant R2
  participant Database
  Client->>VerseAudioRoutes: Send verse audio request
  VerseAudioRoutes->>AccessMiddleware: Authenticate and authorize scope
  AccessMiddleware-->>VerseAudioRoutes: Allow request
  VerseAudioRoutes->>VerseAudioService: Execute upload, read, list, or delete
  VerseAudioService->>R2: Store, remove, or presign audio object
  VerseAudioService->>Database: Claim or update storage and recording metadata
  Database-->>VerseAudioService: Return persistence result
  VerseAudioService-->>VerseAudioRoutes: Return service result
  VerseAudioRoutes-->>Client: Return HTTP response
Loading

Possibly related PRs

Suggested reviewers: anumonachan, mattrace-gloo, kaseywright

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: per-verse audio recording upload and serving support for mobile clients.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/verse-audio

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@henrique221
henrique221 requested a review from kaseywright July 29, 2026 22:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/domains/verse-audio/verse-audio.service.ts`:
- Around line 38-61: In src/domains/verse-audio/verse-audio.service.ts lines
38-61, serialize upload mutations per (projectUnitId, bibleTextId) and persist a
recoverable upload state before blob changes so a failed repo.upsert can be
reconciled or retried. In src/domains/verse-audio/verse-audio.service.ts lines
97-114, make deletion conditional on the recording version/state observed by the
request, persist a durable deletion state before removing the blob, and retry
cleanup without deleting newer uploads. Add coverage for concurrent
upload/delete interleavings and database failures after blob operations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3968741-d8a6-470c-8d43-6476f2d0ad10

📥 Commits

Reviewing files that changed from the base of the PR and between 3fd1027 and 3c62ce8.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (21)
  • .env.example
  • .gitignore
  • .prettierignore
  • eslint.config.mjs
  • package.json
  • src/app.ts
  • src/db/migrations/0015_add_verse_audio_recordings.sql
  • src/db/migrations/meta/0015_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/db/schema.ts
  • src/domains/verse-audio/verse-audio-auth.middleware.ts
  • src/domains/verse-audio/verse-audio.repository.ts
  • src/domains/verse-audio/verse-audio.route.ts
  • src/domains/verse-audio/verse-audio.service.test.ts
  • src/domains/verse-audio/verse-audio.service.ts
  • src/domains/verse-audio/verse-audio.types.ts
  • src/env.ts
  • src/lib/audio-storage.test.ts
  • src/lib/audio-storage.ts
  • src/lib/types.ts
  • src/routes/doc.route.test.ts

Comment thread src/domains/verse-audio/verse-audio.service.ts Outdated
Agent tooling checks out other branches into .claude/worktrees/<branch>/ —
full repo copies whose suites vitest collected, reporting another branch's
29 failures as ours. Excluded there and gitignored; unscoped npm test is
183/183 again. CI unaffected (clean checkouts have no .claude/).
Storage: swaps Azure Blob for R2 (S3-compatible) behind the same exported
surface, pinned to the EU jurisdiction endpoint for GDPR data-at-rest. The
presigner is async, so generateAudioDownloadUrl now returns a promise.

Orphan reclaim (reviewer call on #224): a storage_objects table records every
object written (bucket, key, timestamps) and verse_audio_recordings references
it WITHOUT cascading. Dropping a project unit cascades the recordings away but
Postgres cannot delete an object, so the storage row survives as the marker a
periodic sweep uses to delete the bytes and stamp deletedAt. Chosen over a DB
trigger because triggers are invisible from application code.

The claim is written before the upload, so a crash between the two leaves a
reclaimable marker rather than untracked bytes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/domains/verse-audio/verse-audio.service.ts`:
- Around line 116-139: Make deletion and reclamation mutually exclusive with
claim/re-upload: in src/domains/verse-audio/verse-audio.service.ts:116-139,
perform a durable recoverable unlink/delete transition via the repository before
deleting object bytes; in
src/domains/verse-audio/storage-objects.repository.ts:24-44, make claim honor an
in-progress reclamation lease/state; in
src/domains/verse-audio/storage-objects.repository.ts:69-89, atomically reserve
orphan rows; and in src/domains/verse-audio/verse-audio.service.ts:149-169,
delete only objects held by that reservation. Add coverage for database-remove
failure and reclaim-versus-reupload interleavings.

In `@src/env.ts`:
- Around line 60-76: The storage configuration lacks a way to target MinIO. In
src/env.ts lines 60-76, add an optional URL-validated S3 endpoint setting; in
src/lib/audio-storage.ts lines 54-57, update the client configuration to prefer
that endpoint when provided and otherwise retain the derived R2 endpoint.

In `@src/index.ts`:
- Around line 48-57: Update startup initialization around initializeAudioStorage
and audioReclaimInterval to await the R2 probe before scheduling the reclaim
worker. Only enable the interval after the probe succeeds, while preserving the
existing disabled behavior when audio storage is unconfigured; allow invalid
credentials, bucket names, or jurisdictions to fail during startup.

In `@src/lib/audio-storage.ts`:
- Around line 70-77: Configure the S3Client construction in audio storage with a
requestHandler that enforces finite connection and socket timeouts for all R2
operations. Preserve the existing region, endpoint, path-style addressing, and
credentials, and use the AWS Node HTTP handler appropriate for the project’s SDK
version.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 44332c46-6ad8-4975-931a-7db266bd43d2

📥 Commits

Reviewing files that changed from the base of the PR and between 3c62ce8 and 3a9013f.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (17)
  • .env.example
  • .gitignore
  • package.json
  • src/db/migrations/0016_add_storage_objects.sql
  • src/db/migrations/meta/0016_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/db/schema.ts
  • src/domains/verse-audio/storage-objects.repository.ts
  • src/domains/verse-audio/verse-audio.repository.ts
  • src/domains/verse-audio/verse-audio.service.test.ts
  • src/domains/verse-audio/verse-audio.service.ts
  • src/domains/verse-audio/verse-audio.types.ts
  • src/env.ts
  • src/index.ts
  • src/lib/audio-storage.test.ts
  • src/lib/audio-storage.ts
  • vitest.config.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • .gitignore

Comment thread src/domains/verse-audio/verse-audio.service.ts
Comment thread src/env.ts Outdated
Comment thread src/index.ts Outdated
Comment thread src/lib/audio-storage.ts
Review follow-ups on the storage-objects work:

- deleteRecording now removes the row BEFORE the object. A failed row write
  destroys nothing and can be retried; a failed object delete leaves an orphan
  the sweep collects. The old order could strand a recording pointing at bytes
  that were already gone, which no sweep can repair.
- The reclaim sweep skips rows younger than AUDIO_RECLAIM_GRACE_MS, and claim()
  refreshes createdAt on revive. An upload claims its row before writing the
  object, so without this a sweep landing mid-upload could delete live bytes.
- initializeAudioStorage() is now actually called: the sweep only starts once
  the bucket answers. Audio is optional, so a failed probe logs and leaves the
  API serving rather than killing startup.
- S3 client gets connection/request timeouts (the SDK defaults to none, so an
  unreachable endpoint would hang uploads and the sweep indefinitely).
- R2_ENDPOINT override makes the documented MinIO local-dev path real.
- Drops @aws-sdk/lib-storage: recordings are capped at 30 MB and buffered, so
  PutObject is used and the multipart helper was never imported.
@kaseywright

Copy link
Copy Markdown
Contributor

This looks good. I'm happy to merge as soon as we have the r2 configuration in place.

kaseywright
kaseywright previously approved these changes Aug 4, 2026
# Conflicts:
#	package-lock.json
#	package.json
#	src/index.ts

@kaseywright kaseywright left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: bucket-name defaults risk silently mixing/overwriting user audio across environments

Since #212 landed, R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY are now shared between the exports and verse-audio R2 integrations (src/env.ts), which is a good consolidation. But each feature still has its own bucket var with a hardcoded literal default:

R2_EXPORTS_BUCKET: z.string().default('usfm-exports'),
R2_AUDIO_BUCKET: z.string().default('verse-audio'),

The problem: if the same R2 account is ever reused across environments (dev/staging/prod — a common cost-saving setup) and an environment's deploy config omits R2_AUDIO_BUCKET, that environment doesn't fail to start — it silently falls back to the literal bucket name "verse-audio". If another environment is also missing the override (or was configured before this var existed), both now point at the same real bucket. Because verse-audio object keys are deterministic (unit-{id}/text-{id} in audio-storage.ts), this isn't a "wrong bucket, 404" failure — it's two environments silently reading and overwriting the same objects. A dev/staging upload would clobber production audio for the same verse, with no error anywhere.

This is worse than a missing-bucket error because it's silent and destructive rather than loud and safe.

Compounding gap: even when the bucket genuinely doesn't exist, the failure mode is soft. initializeAudioStorage()'s HeadBucketCommand check in src/index.ts is wrapped in try/catch — a failure just logs and disables the reclaim sweep; it doesn't stop the server or gate the routes. The PUT/GET/DELETE verse-audio handlers only check isAudioStorageConfigured() (credential presence), never bucket reachability, so a bad bucket name surfaces as scattered per-request 500s instead of a clean boot-time refusal.

Requested change: drop the hardcoded .default(...) on R2_AUDIO_BUCKET and R2_EXPORTS_BUCKET. Require both explicitly whenever R2 credentials are configured (e.g. EnvSchema.superRefine, or an explicit check at startup) so a missing bucket var fails the boot loudly instead of quietly resolving to a name another environment might already own. Whatever the specific mechanism, no environment should be able to silently inherit a bucket name it didn't explicitly choose — that's the only thing that actually closes the collision risk, independent of which string is picked as a "default."

Everything else in this pass (the credential consolidation itself) looks correct and I don't have other blocking concerns.

R2 credentials are account-level, so environments pointed at the same account
are separated by nothing but their bucket names. R2_EXPORTS_BUCKET and
R2_AUDIO_BUCKET each carried a hardcoded default, so an environment whose
deploy config omitted one still booted, on the fallback name. Audio keys are
deterministic (unit-{id}/text-{id}), so two environments landing on the same
name would read and OVERWRITE each other's recordings rather than miss and
404 — silent and destructive instead of loud and safe (kaseywright, #224).

Both vars are now optional in the schema and required by a superRefine as soon
as any R2 credential is present, so a missing one fails env validation before
anything starts. audioBucket()/exportsBucket() are the single accessors that
narrow the type, with that invariant documented in place rather than asserted
at each call site.

An unreachable bucket stays non-fatal, per the #212 call that R2 trouble must
not take the whole API down: initializeAudioStorage now records its probe
result and the verse-audio routes read it through isAudioStorageAvailable, so
a bad bucket answers one clean 503 instead of a 500 per request.

.env.example documents the real per-environment bucket names, since the
invented ones were exactly the trap.

Refs: #224

Claude-Session: https://claude.ai/code/session_016ej1G6uozJyR5TYwypKxfz
@henrique221

Copy link
Copy Markdown
Contributor Author

dropped both defaults in d6f3190. R2_EXPORTS_BUCKET and R2_AUDIO_BUCKET are plain optionals now and a superRefine requires both as soon as any R2 credential is present, so an environment that forgets one dies in env validation before the server starts instead of quietly inheriting a name another environment owns. audioBucket() / exportsBucket() are the single accessors that narrow the type so the invariant is documented in one place instead of asserted at every call site. .env.example now names the real buckets per environment (fluent-exports-{dev,qa,prod}, fluent-audio-recordings-{dev,qa,prod}) — the invented names were the trap.

on the compounding gap, I kept your #212 call intact rather than trading one for the other: a missing env var now fails boot, but an unreachable bucket still does not take the API down. initializeAudioStorage records its probe result and the verse-audio routes read it via isAudioStorageAvailable(), so a bad bucket is one clean 503 on those routes instead of scattered per-request 500s, and everything else keeps serving.

src/env.test.ts covers the new rule (creds without buckets, blank bucket, partial creds).

@henrique221
henrique221 requested a review from kaseywright August 6, 2026 19:13

@kaseywright kaseywright left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — the bucket-default issue is resolved

Commit d6f3190 directly fixes the silent cross-environment collision risk from my previous review:

  • R2_EXPORTS_BUCKET / R2_AUDIO_BUCKET no longer have hardcoded defaults — both are optional in the base schema, enforced by a superRefine (requireExplicitR2Buckets) that fails env validation at boot if any R2 credential is set but a bucket name is missing or blank. An environment can no longer silently inherit another environment's bucket by omission; it fails loudly instead.
  • .env.example now documents real per-environment bucket names instead of inviting-looking placeholders.
  • Bonus fix for the compounding gap I flagged as secondary: the verse-audio routes previously only checked credential presence, so an unreachable bucket surfaced as scattered 500s. initializeAudioStorage() now records its boot-time probe result, and the routes check a new isAudioStorageAvailable() so a bad bucket now answers a clean 503 instead.
  • Good, targeted test coverage: src/env.test.ts (new) exercises the superRefine directly — missing creds+bucket, blank bucket, partial credentials, fully-specified — and audio-storage.test.ts covers the availability-flag behavior.

Verified locally: env.test.ts, audio-storage.test.ts, verse-audio.service.test.ts, and blob-storage.test.ts all pass, and tsc --noEmit is clean.

No remaining concerns from me. Nice, precise fix.

@henrique221
henrique221 merged commit 68e0c15 into main Aug 6, 2026
1 check passed
@github-actions
github-actions Bot deleted the feat/verse-audio branch August 6, 2026 21:13
@coderabbitai coderabbitai Bot mentioned this pull request Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants