Aviary
Recipes

Writing a custom store

Implement the MediaStore contract for any persistence backend, verified by the shared conformance suite.

The four bundled ORM stores cover the common cases, but the contract is small — back media records with anything (a different ORM, a document store, a service API) by implementing five methods.

The contract

import type { MediaRecord, MediaStore } from '@dudousxd/nestjs-media-core';

export class MyMediaStore implements MediaStore {
  constructor(private readonly db: MyDb) {}

  async save(record: MediaRecord): Promise<MediaRecord> { /* upsert by id */ return record; }
  async find(id: string): Promise<MediaRecord | null> { /* … */ }
  async listByOwner(ownerType: string, ownerId: string, collection?: string): Promise<MediaRecord[]> {
    /* filter by owner (+ collection if given), ORDER BY order ASC */
  }
  async delete(id: string): Promise<void> { /* idempotent */ }
  async nextOrder(ownerType: string, ownerId: string, collection: string): Promise<number> {
    /* MAX(order)+1 in the collection, or 0 */
  }
}

Semantics that matter

  • save is an upsertMediaLibrary calls it both to create and to update (when a conversion is added). Key on id.
  • listByOwner is ordered — return records sorted by order ascending; with no collection, return all of the owner's collections.
  • delete is idempotent — deleting a missing id is a no-op, not an error.
  • nextOrder returns the append position0 for an empty collection.

These four behaviors are exactly what the conformance suite checks — they're the assumptions the media-library relies on.

The query SPI (admin)

The five methods above are all the media-library needs at runtime. The console asks more of a store — it browses every owner's records, not one owner's collection — so MediaStore carries three optional query methods on top:

interface MediaStore {
  // …the five required methods…

  count?(filter?: MediaCountFilter): Promise<number>;
  aggregate?(query: MediaAggregateQuery): Promise<MediaAggregateResult>;
  list?(filter?: MediaListFilter, page?: MediaListPage): Promise<MediaListResult>;
}

interface MediaCountFilter { ownerType?: string; collection?: string; disk?: string }

interface MediaAggregateQuery {
  groupBy: 'collection' | 'disk';   // column to group rows by
  sum?: 'size';                      // include a summed byte total per group when 'size'
}
type MediaAggregateResult = MediaAggregateBucket[];
interface MediaAggregateBucket { key: string; count: number; sumSize: number } // sumSize 0 if sum omitted

interface MediaListFilter { ownerType?: string; collection?: string; disk?: string }
interface MediaListPage { cursor?: string; limit?: number }   // limit defaults to 50
interface MediaListResult { records: MediaRecord[]; cursor?: string } // cursor absent on the last page
  • count — a global record count across all owners, optionally narrowed by the filter (fields AND together; omit for everything). Powers the console's totals.
  • aggregate — a group-by rollup: one { key, count, sumSize } bucket per distinct collection (or disk), with sumSize populated only when you request sum: 'size'. Powers the console's per-collection view.
  • list — a paginated global listing across all owners, filtered like count. Ordered by (createdAt, id) ascending with an opaque keyset cursor: pass the returned cursor back as page.cursor for the next page, stop when it's absent.

Optional means non-breaking

These are additive — an external store that omits them still boots and serves the whole media-library. The dashboard providers degrade gracefully: a store without them just reports empty/zero shapes in the admin views (listLibrary{ records: [] }, listCollections{ collections: [] }). Implement them only if you want your custom store to light up the console.

export class MyMediaStore implements MediaStore {
  // …required methods…

  async count(filter?: MediaCountFilter): Promise<number> {
    return this.db.media.count({ where: whereFrom(filter) }); // AND the given fields
  }

  async aggregate(query: MediaAggregateQuery): Promise<MediaAggregateResult> {
    const rows = await this.db.media.groupBy({
      by: [query.groupBy],
      _count: true,
      ...(query.sum === 'size' ? { _sum: { size: true } } : {}),
    });
    return rows.map((r) => ({ key: r[query.groupBy], count: r._count, sumSize: r._sum?.size ?? 0 }));
  }

  async list(filter?: MediaListFilter, page?: MediaListPage): Promise<MediaListResult> {
    const limit = page?.limit ?? 50;
    const rows = await this.db.media.findMany({
      where: whereFrom(filter),
      orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
      take: limit + 1,                              // fetch one extra to detect a next page
      ...(page?.cursor ? { cursor: decodeCursor(page.cursor), skip: 1 } : {}),
    });
    const hasMore = rows.length > limit;
    const records = hasMore ? rows.slice(0, limit) : rows;
    return { records, ...(hasMore ? { cursor: encodeCursor(records.at(-1)!) } : {}) };
  }
}

Verify it

my-store.spec.ts
import { runMediaStoreConformance } from '@dudousxd/nestjs-media-testing';
import { MyMediaStore } from './my-store';

runMediaStoreConformance('MyMediaStore', () => new MyMediaStore(makeFreshDb()));

Pass the suite and your store drops into MediaModule unchanged:

MediaModule.forRoot({ default: 's3', disks: { s3 }, store: new MyMediaStore(db) });

For a real backend, also run it as a *.db.spec.ts against a container (the way the ORM stores are validated on Postgres). See -testing and Persistence.

On this page