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
saveis an upsert —MediaLibrarycalls it both to create and to update (when a conversion is added). Key onid.listByOwneris ordered — return records sorted byorderascending; with nocollection, return all of the owner's collections.deleteis idempotent — deleting a missing id is a no-op, not an error.nextOrderreturns the append position —0for 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 pagecount— 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 distinctcollection(ordisk), withsumSizepopulated only when you requestsum: 'size'. Powers the console's per-collection view.list— a paginated global listing across all owners, filtered likecount. Ordered by(createdAt, id)ascending with an opaque keyset cursor: pass the returnedcursorback aspage.cursorfor 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
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.