Все статьи🇷🇺 Русский
4 мин

Почему для музыкального сервиса с пользовательскими файлами я выбрал Drizzle ORM

Рассказываю, чем Drizzle помогает проекту с тысячами пользовательских треков, как она упрощает хранение метаданных и контроль жизненного цикла файлов

drizzle-ormpostgresqlbackendmusic-techarchitecture

Мой новый музыкальный сервис генерирует и хранит тонны пользовательских данных: исходные mp3, платные wav, метаданные по срокам жизни и потоковым правам доступа. Нам нужен ORM, который не скрывает SQL, но при этом даёт безопасные типы и быстрые миграции. Именно поэтому я выбрал Drizzle ORM.

Контекст: какие задачи нужно закрыть

  • Пользователь загружает или генерирует mp3, а потом может докупить конверсию в wav.
  • Для бесплатных аккаунтов файлы живут 7 дней, дальше их нужно очищать без ручного вмешательства.
  • Мы стримим треки по Range-запросам и хотим понимать, какой формат хранится и где (локальный диск, сетевое хранилище, S3 в будущем).
  • В миграциях важно жёстко контролировать enum'ы и внешние ключи, иначе появятся «битые» файлы.

Почему не Prisma и не TypeORM

КритерийTypeORMPrismaDrizzle
Контроль SQL (ENUM, ON DELETE CASCADE)Ограничен декораторамиНужны raw-запросыПолноценные билдеры + raw
Runtime overheadДекораторы/рефлексияГенератор + клиентНоль рантайма, только compile-time
Структурирование схемыОдин монолитный файлОдна модель целикомМожно делить на модули (users.ts, tracks.ts)
МиграцииTypeORM CLI, медленноprisma migrate, сложно кастомизироватьdrizzle-kit, plain SQL/TS

Для проекта, где схема растёт каждую неделю, важны простота миграций и типобезопасные SQL-конструкции без «магии». Здесь Drizzle выигрывает.

Схема под треки и их файлы

Drizzle даёт выразительный DSL, поэтому таблицы выглядят так же прозрачно, как и чистый SQL:

export const trackLifecycleEnum = pgEnum('track_lifecycle_state', ['active', 'expired', 'deleted']);
export const assetFormatEnum = pgEnum('track_asset_format', ['mp3', 'wav']);

export const tracks = pgTable('tracks', {
  id: serial('id').primaryKey(),
  userId: integer('user_id').notNull().references(() => users.id),
  title: varchar('title', { length: 255 }).notNull(),
  lifecycleState: trackLifecycleEnum('lifecycle_state').default('active').notNull(),
  expiresAt: timestamp('expires_at'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const trackAssets = pgTable('track_assets', {
  id: serial('id').primaryKey(),
  trackId: integer('track_id').references(() => tracks.id, { onDelete: 'cascade' }).notNull(),
  format: assetFormatEnum('format').notNull(),
  isPremium: boolean('is_premium').default(false).notNull(),
  storagePath: text('storage_path').notNull(),
  storageProvider: varchar('storage_provider', { length: 64 }).notNull(),
  expiresAt: timestamp('expires_at'),
  lifecycleState: trackLifecycleEnum('lifecycle_state').default('active').notNull(),
});

Здесь сразу видно, какие состояния допускает система, а каскадное удаление гарантирует, что при очистке истёкшего трека мы не оставим «висящих» файлов.

Управление жизненным циклом и платной конверсией

  • Enum'ы и expiresAt позволяют хранить правила на уровне базы: если файл платный (isPremium = true), мы просто не ставим дату истечения.
  • Можно добавить индексы (index('track_assets_lifecycle_idx').on(table.lifecycleState, table.expiresAt)), чтобы воркеру очистки было достаточно одного запроса — никакой сложной ORM-логики.
  • storageProvider фиксирует, где лежит файл. Как только мы подключим S3, достаточно добавить новое значение в конфиг, а схема останется прежней.

Typed-запросы для потоковой отдачи

Когда пользователь жмёт «Play», нам нужно быстро найти актуальный ассет и подготовить поток:

const asset = await db
  .select()
  .from(trackAssets)
  .where(and(eq(trackAssets.trackId, trackId), eq(trackAssets.format, requestedFormat)))
  .limit(1);

В ответе я получаю строго типизированный объект с путём и провайдером. Дальше StorageService решает, читать файл с локального диска или отдавать ссылку на сетевой NAS. Никаких any, никакой угадай-структуры.

Миграции без боли

drizzle-kit генерирует миграции на основании схемы, но всегда оставляет итоговые файлы читабельными. Для нас важно:

  1. Сгенерировать enum'ы и таблицы.
  2. Протянуть индексы под cron-задачи.
  3. Катить миграции через CI/CD без сюрпризов.

Drizzle делает это быстро, а в репозитории лежит чистый SQL/TypeScript, который понятно ревьюить. Если завтра мы решим хранить ещё и flac, я меняю один enum и перекатываю миграцию.

Итог

Drizzle ORM оказалась идеальной серединой между «ORM, который скрывает всё», и «ручным SQL». Для музыкального сервиса это значит:

  • Типобезопасная и модульная схема, куда легко добавлять новые форматы.
  • Чёткий контроль над жизненным циклом файлов и политиками хранения.
  • Прозрачные миграции и отсутствие лишнего рантайм-кода.

В результате мы быстрее внедряем новые возможности (платный wav, автоудаление через 7 дней, стриминг с Range) и уверены, что данные пользователей ведут себя предсказуемо.