Skip to content

Commit

Permalink
refactor: rename storageKey to objectKey for Tigris object storage
Browse files Browse the repository at this point in the history
also write decision doc and documentation
  • Loading branch information
kentcdodds committed Feb 21, 2025
1 parent 128c161 commit 5075d92
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 45 deletions.
4 changes: 2 additions & 2 deletions app/routes/resources+/note-images.$imageId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ export async function loader({ params }: Route.LoaderArgs) {
invariantResponse(params.imageId, 'Image ID is required', { status: 400 })
const noteImage = await prisma.noteImage.findUnique({
where: { id: params.imageId },
select: { storageKey: true },
select: { objectKey: true },
})
invariantResponse(noteImage, 'Note image not found', { status: 404 })

const { url, headers } = getSignedGetRequestInfo(noteImage.storageKey)
const { url, headers } = getSignedGetRequestInfo(noteImage.objectKey)
const response = await fetch(url, { headers })

const cacheHeaders = new Headers(response.headers)
Expand Down
4 changes: 2 additions & 2 deletions app/routes/resources+/user-images.$imageId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ export async function loader({ params }: Route.LoaderArgs) {
invariantResponse(params.imageId, 'Image ID is required', { status: 400 })
const userImage = await prisma.userImage.findUnique({
where: { id: params.imageId },
select: { storageKey: true },
select: { objectKey: true },
})
invariantResponse(userImage, 'User image not found', { status: 404 })

const { url, headers } = getSignedGetRequestInfo(userImage.storageKey)
const { url, headers } = getSignedGetRequestInfo(userImage.objectKey)
const response = await fetch(url, { headers })

const cacheHeaders = new Headers(response.headers)
Expand Down
2 changes: 1 addition & 1 deletion app/routes/settings+/profile.photo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function action({ request }: Route.ActionArgs) {
intent: data.intent,
image: {
contentType: data.photoFile.type,
storageKey: await uploadProfileImage(userId, data.photoFile),
objectKey: await uploadProfileImage(userId, data.photoFile),
},
}
}),
Expand Down
6 changes: 3 additions & 3 deletions app/routes/users+/$username_+/__note-editor.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export async function action({ request }: ActionFunctionArgs) {
id: i.id,
altText: i.altText,
contentType: i.file.type,
storageKey: await uploadNoteImage(userId, noteId, i.file),
objectKey: await uploadNoteImage(userId, noteId, i.file),
}
} else {
return {
Expand All @@ -75,7 +75,7 @@ export async function action({ request }: ActionFunctionArgs) {
return {
altText: image.altText,
contentType: image.file.type,
storageKey: await uploadNoteImage(userId, noteId, image.file),
objectKey: await uploadNoteImage(userId, noteId, image.file),
}
}),
),
Expand Down Expand Up @@ -119,7 +119,7 @@ export async function action({ request }: ActionFunctionArgs) {
data: {
...updates,
// If the image is new, we need to generate a new ID to bust the cache.
id: updates.storageKey ? cuid() : updates.id,
id: updates.objectKey ? cuid() : updates.id,
},
})),
create: newImages,
Expand Down
2 changes: 1 addition & 1 deletion app/utils/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export async function signupWithConnection({
image: {
create: {
contentType: imageFile.type,
storageKey: await uploadProfileImage(user.id, imageFile),
objectKey: await uploadProfileImage(user.id, imageFile),
},
},
},
Expand Down
5 changes: 3 additions & 2 deletions docs/decisions/018-images.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Images

Date: 2023-06-23
Date: 2023-06-23 Updated: 2024-03-19

Status: accepted (for now)
Status: superseded by
[040-tigris-image-storage.md](./040-tigris-image-storage.md)

## Context

Expand Down
67 changes: 67 additions & 0 deletions docs/decisions/040-tigris-image-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Title: Switch to Tigris for Image Storage

Date: 2025-02-20

Status: accepted

## Context

The Epic Stack previously stored uploaded images directly in SQLite using binary
data storage. While this approach is simple and works well for small
applications, it has several limitations (as noted in the previous decision
[018-images.md](docs/decisions/018-images.md)):

1. Binary data in SQLite increases database size and backup complexity
2. Large binary data in SQLite can impact database performance
3. SQLite backups become larger and more time-consuming when including binary
data
4. No built-in CDN capabilities for serving images efficiently

## Decision

We will switch from storing images in SQLite to storing them in Tigris, an
S3-compatible object storage service. This change will:

1. Move binary image data out of SQLite into specialized object storage
2. Maintain metadata about images in SQLite (references, ownership, etc.)
3. Leverage Tigris's S3-compatible API for efficient image storage and retrieval
4. Enable better scalability for applications with many image uploads

To keep things lightweight, we will not be using an S3 SDK to integrate with
Tigris and instead we'll manage authenticated fetch requests ourselves.

## Consequences

### Positive

1. Reduced SQLite database size and improved backup efficiency
2. Better separation of concerns (binary data vs relational data)
3. Potentially better image serving performance through Tigris's infrastructure
4. More scalable solution for applications with heavy image usage
5. Easier to implement CDN capabilities in the future
6. Simplified database maintenance and backup procedures
7. Tigris storage is much cheaper than Fly volume storage

### Negative

1. Additional external service dependency (though Fly as built-in support and no
additional account needs to be created)
2. Need to manage Tigris configuration
3. Slightly more complex deployment setup
4. Additional complexity in image upload and retrieval logic

## Implementation Notes

The implementation involves:

1. Setting up Tigris configuration
2. Modifying image upload handlers to store files in Tigris
3. Updating image retrieval routes to serve from Tigris
4. Maintaining backward compatibility during migration (database migration is
required as well as manual migration of existing images)
5. Providing migration utilities for existing applications

## References

- [Tigris Documentation](https://www.tigris.com/docs)
- Previous image handling: [018-images.md](docs/decisions/018-images.md)
79 changes: 79 additions & 0 deletions docs/image-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Image Storage

The Epic Stack uses [Tigris](https://www.tigris.com), an S3-compatible object
storage service, for storing and serving uploaded images. Tigris is integrated
tightly with Fly.io, so you don't need to worry about setting up an account or
configuring any credentials.

## Configuration

To use Tigris for image storage, you need to configure the following environment
variables. These are automatically set for you on Fly.io when you create storage
for your app which happens when you create a new Epic Stack project.

```sh
AWS_ACCESS_KEY_ID="mock-access-key"
AWS_SECRET_ACCESS_KEY="mock-secret-key"
AWS_REGION="auto"
AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev"
BUCKET_NAME="mock-bucket"
```

These environment variables are set automatically in the `.env` file locally and
a mock with MSW is set up so that everything works completely offline locally
during development.

## How It Works

The Epic Stack maintains a hybrid approach to image storage:

1. Image metadata (relationships, ownership, etc.) is stored in SQLite
2. The actual image binary data is stored in Tigris
3. Image URLs point to the local server which proxies to Tigris

### Database Schema

The database schema maintains references to images while the actual binary data
lives in Tigris:

```prisma
model UserImage {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
objectKey String // Reference to the image in Tigris
contentType String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
model NoteImage {
id String @id @default(cuid())
noteId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
objectKey String // Reference to the image in Tigris
contentType String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([noteId])
}
```

### Image Upload Flow

1. When an image is uploaded, it's first processed by the application
(validation, etc.)
2. The image is then streamed to Tigris
3. The metadata is stored in SQLite with a reference to the Tigris object key
4. The image can then be served by proxying to Tigris

## Customization

For more details on customization, see the source code in:

- `app/utils/storage.server.ts`
- `app/routes/resources+/note-images.$imageId.tsx`
- `app/routes/resources+/user-images.$imageId.tsx`
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ CREATE TABLE "NoteImage" (
"id" TEXT NOT NULL PRIMARY KEY,
"altText" TEXT,
"contentType" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"objectKey" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"noteId" TEXT NOT NULL,
Expand All @@ -36,7 +36,7 @@ CREATE TABLE "UserImage" (
"id" TEXT NOT NULL PRIMARY KEY,
"altText" TEXT,
"contentType" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"objectKey" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
Expand Down Expand Up @@ -189,7 +189,6 @@ CREATE UNIQUE INDEX "_RoleToUser_AB_unique" ON "_RoleToUser"("A", "B");
CREATE INDEX "_RoleToUser_B_index" ON "_RoleToUser"("B");



--------------------------------- Manual Seeding --------------------------
-- Hey there, Kent here! This is how you can reliably seed your database with
-- some data. You edit the migration.sql file and that will handle it for you.
Expand Down
4 changes: 2 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ model NoteImage {
id String @id @default(cuid())
altText String?
contentType String
storageKey String
objectKey String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand All @@ -67,7 +67,7 @@ model UserImage {
id String @id @default(cuid())
altText String?
contentType String
storageKey String
objectKey String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down
24 changes: 12 additions & 12 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async function seed() {
userId: user.id,
contentType: userImage.contentType,
altText: userImage.altText,
storageKey: userImage.storageKey,
objectKey: userImage.objectKey,
},
})
}
Expand Down Expand Up @@ -67,7 +67,7 @@ async function seed() {
noteId: note.id,
contentType: noteImage.contentType,
altText: noteImage.altText,
storageKey: noteImage.storageKey,
objectKey: noteImage.objectKey,
},
})
}
Expand All @@ -79,35 +79,35 @@ async function seed() {
console.time(`🐨 Created admin user "kody"`)

const kodyImages = await promiseHash({
kodyUser: img({ storageKey: 'user/kody.png' }),
kodyUser: img({ objectKey: 'user/kody.png' }),
cuteKoala: img({
altText: 'an adorable koala cartoon illustration',
storageKey: 'kody-notes/cute-koala.png',
objectKey: 'kody-notes/cute-koala.png',
}),
koalaEating: img({
altText: 'a cartoon illustration of a koala in a tree eating',
storageKey: 'kody-notes/koala-eating.png',
objectKey: 'kody-notes/koala-eating.png',
}),
koalaCuddle: img({
altText: 'a cartoon illustration of koalas cuddling',
storageKey: 'kody-notes/koala-cuddle.png',
objectKey: 'kody-notes/koala-cuddle.png',
}),
mountain: img({
altText: 'a beautiful mountain covered in snow',
storageKey: 'kody-notes/mountain.png',
objectKey: 'kody-notes/mountain.png',
}),
koalaCoder: img({
altText: 'a koala coding at the computer',
storageKey: 'kody-notes/koala-coder.png',
objectKey: 'kody-notes/koala-coder.png',
}),
koalaMentor: img({
altText:
'a koala in a friendly and helpful posture. The Koala is standing next to and teaching a woman who is coding on a computer and shows positive signs of learning and understanding what is being explained.',
storageKey: 'kody-notes/koala-mentor.png',
objectKey: 'kody-notes/koala-mentor.png',
}),
koalaSoccer: img({
altText: 'a cute cartoon koala kicking a soccer ball on a soccer field ',
storageKey: 'kody-notes/koala-soccer.png',
objectKey: 'kody-notes/koala-soccer.png',
}),
})

Expand All @@ -132,7 +132,7 @@ async function seed() {
userId: kody.id,
contentType: kodyImages.kodyUser.contentType,
altText: kodyImages.kodyUser.altText,
storageKey: kodyImages.kodyUser.storageKey,
objectKey: kodyImages.kodyUser.objectKey,
},
})

Expand Down Expand Up @@ -241,7 +241,7 @@ async function seed() {
noteId: note.id,
contentType: image.contentType,
altText: image.altText,
storageKey: image.storageKey,
objectKey: image.objectKey,
},
})
}
Expand Down
Loading

0 comments on commit 5075d92

Please sign in to comment.