@mostajs/repository
v0.1.1
Published
Generic Repository<T> abstraction over @mostajs/data-plug. CRUD + query DSL + composable middlewares (tenant scoping, soft-delete, audit). Backend-agnostic: Mongo / Postgres / REST decided by MOSTA_DATA env.
Readme
@mostajs/repository
Auteur : Dr Hamid MADANI [email protected] License : AGPL-3.0-or-later Version : 0.1.0
Repository pattern générique pour l'écosystème @mostajs/*. Une abstraction unique pour stocker des entités structurées, quelle que soit la couche persistance dessous : MongoDB, Postgres, REST, in-memory.
Le module fournit :
- L'interface
Repository<T>(CRUD + query DSL MongoDB-like) - Une implémentation
createRepositoryqui consomme@mostajs/data-plug(dispatch vers le bon backend selonMOSTA_DATAenv) - Une implémentation
createMemoryRepositoryin-memory pour tests/dev - 3 middlewares composables :
withTenantScope,withSoftDelete,withAuditTrail - Une API minimaliste mais suffisante pour 90 % des CRUD applicatifs
Table des matières
- Pourquoi un repository générique ?
- Architecture
- Quick start — how to use
- API détaillée
- Implémentation — how to impl
- Middlewares composables
- Patterns avancés
- Tests
- Troubleshooting
- Modules liés
1. Pourquoi un repository générique ?
Sans repository, chaque consumer écrit son propre accès au store :
// Sans repository — dispersion, fragilité
const session = await mongoose.model('Session').findOne({ id }) // si Mongo
const session = await prisma.session.findUnique({ where: { id } }) // si Postgres
const session = await fetch('/api/sessions/' + id).then(r => r.json()) // si RESTAvec repository, une seule signature quel que soit le backend :
// Avec @mostajs/repository
const session = await sessionRepo.findById(id)L'app décide le backend via MOSTA_DATA (cf. @mostajs/data-plug), sans toucher au code applicatif :
MOSTA_DATA=mongodb://localhost:27017/myapp # → Mongo
MOSTA_DATA=postgres://localhost/myapp # → Postgres
MOSTA_DATA=rest+https://api.example.com # → REST proxy
MOSTA_DATA= # → in-memory (dev/test)Bénéfices :
- Portabilité : un module @mostajs/* publié sur npm s'utilise dans une app Mongo OU une app Postgres OU une app REST sans modification.
- Testabilité :
createMemoryRepositorypour tests unitaires ultra-rapides, sans Docker / DB locale. - Cohérence : tous les modules
@mostajs/*parlent la même API → consumer apprend une fois. - Évolutivité : changer de backend = changer une env var, pas refondre le code.
2. Architecture
┌──────────────────────────────────────────────────────────────┐
│ Code applicatif (manager / controller / service) │
│ appelle : │
│ sessionRepo.findById('abc') │
│ bookingRepo.find({ status: 'open' }) │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Middlewares (composables, inside-out) │
│ ┌──────────────────────┐ │
│ │ withAuditTrail │ injecte createdBy / updatedBy │
│ └──────────┬───────────┘ │
│ ┌──────────▼───────────┐ │
│ │ withSoftDelete │ delete = soft, queries filtrent │
│ └──────────┬───────────┘ │
│ ┌──────────▼───────────┐ │
│ │ withTenantScope │ inject { tenantId } partout │
│ └──────────┬───────────┘ │
└─────────────┼────────────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ Repository<T> core (createRepository) │
│ appelle : │
│ plug.findOne(collection, filter, opts) │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ @mostajs/data-plug │
│ dispatch selon MOSTA_DATA : │
│ mongo://... → driver mongodb │
│ postgres://... → driver pg (mapping JSON) │
│ rest+https:// → fetch HTTP CRUD │
│ (vide) → in-memory transient │
└──────────────────────────────────────────────────────────────┘Les middlewares s'appliquent depuis l'extérieur. Convention : envelopper du plus spécifique (audit, soft-delete) vers le plus systémique (tenant scope, qui doit être le dernier appliqué pour intercepter aussi les middlewares au-dessus).
3. Quick start — how to use
Installation
npm install @mostajs/repository @mostajs/data-plugCas le plus simple — CRUD d'une entité
import { createRepository } from '@mostajs/repository'
// Entity = doit avoir un champ `id` (PK)
interface Article {
id: string
title: string
body: string
authorId: string
publishedAt: number
tags: string[]
}
const articles = createRepository<Article>({ collection: 'articles' })
// Create / Update via upsert
await articles.save({
id: 'art-001',
title: 'Hello',
body: '...',
authorId: 'user-42',
publishedAt: Date.now(),
tags: ['intro', 'mostajs'],
})
// Read
const a = await articles.findById('art-001')
const recent = await articles.find(
{ publishedAt: { $gt: Date.now() - 86400000 } },
{ sort: { publishedAt: -1 }, limit: 10 },
)
const byAuthor = await articles.find({ authorId: 'user-42' })
const tagged = await articles.find({ tags: { $in: ['intro'] } })
// Update (patch)
const n = await articles.update({ id: 'art-001' }, { title: 'Hello, World' })
// → 1
// Delete
await articles.delete('art-001')
// → trueBackend in-memory (tests, dev sans DB)
import { createMemoryRepository } from '@mostajs/repository'
const articles = createMemoryRepository<Article>({ collection: 'articles' })
// Même API, pas de DB requise — parfait pour les tests unitairesSortie attendue identique à createRepository, juste sans persistance.
Avec middlewares
import { createRepository, withTenantScope, withSoftDelete, withAuditTrail } from '@mostajs/repository'
import { getCurrentTenantId, getCurrentUser } from '@mostajs/multitenancy'
const articles =
withAuditTrail(
withSoftDelete(
withTenantScope(
createRepository<Article>({ collection: 'articles' }),
{ getTenantId: getCurrentTenantId },
),
),
{ getActor: () => getCurrentUser()?.id },
)
// Tous les appels sont maintenant :
// - filtrés par tenantId (multi-tenant safe)
// - soft-delete (les anciens articles "supprimés" restent en DB)
// - tracés (createdBy / updatedBy / createdAt / updatedAt automatiques)4. API détaillée
Repository<T extends Entity>
interface Entity {
id: string
[k: string]: unknown
}
interface Repository<T extends Entity> {
readonly collection: string
findById(id: string): Promise<T | null>
findOne(filter: Filter<T>, opts?: QueryOptions): Promise<T | null>
find(filter?: Filter<T>, opts?: QueryOptions): Promise<T[]>
count(filter?: Filter<T>): Promise<number>
save(entity: T): Promise<T> // create or replace
upsert(filter: Filter<T>, entity: Partial<T>): Promise<T>
update(filter: Filter<T>, patch: Partial<T>): Promise<number> // returns count of updated rows
delete(id: string): Promise<boolean> // true if a row was deleted
deleteMany(filter: Filter<T>): Promise<number> // returns count of deleted rows
}Filter DSL
// Simple equality (implicite $eq)
await repo.find({ status: 'open' })
// Operators (MongoDB-style)
await repo.find({
publishedAt: { $gt: 1700000000000 }, // > 1.7e12
status: { $in: ['open', 'pending'] },
authorId: { $ne: 'banned-user' },
email: { $regex: '.*@example\\.com' },
metadata: { $exists: true },
})
// Combinable
await repo.find({
authorId: 'user-42',
publishedAt: { $gte: today, $lt: tomorrow },
})Operators supportés : $eq, $ne, $in, $nin, $gt, $gte, $lt, $lte, $exists, $regex.
Note : les operators complexes (
$or,$and,$elemMatch) sont délégués au data-plug — disponibles si le backend les supporte (Mongo, Postgres), peuvent être no-op sur le backend REST.
QueryOptions
interface QueryOptions {
sort?: Record<string, 1 | -1> // { createdAt: -1 } = DESC
limit?: number
offset?: number
projection?: Record<string, 0 | 1> // { id: 1, title: 1 } ou { secret: 0 }
}
await repo.find(
{ authorId: 'user-42' },
{
sort: { publishedAt: -1, title: 1 },
limit: 20,
offset: 40, // pagination : page 3 si 20/page
projection: { id: 1, title: 1, publishedAt: 1 }, // n'envoie pas le body
},
)5. Implémentation — how to impl
Pattern 1 — Module de domaine simple
// my-app/lib/article-repo.ts
import { createRepository } from '@mostajs/repository'
export interface Article {
id: string
title: string
body: string
authorId: string
publishedAt: number
}
export const articleRepo = createRepository<Article>({ collection: 'articles' })// my-app/app/api/articles/[id]/route.ts
import { articleRepo } from '@/lib/article-repo'
export async function GET(_req: Request, { params }) {
const article = await articleRepo.findById(params.id)
if (!article) return new Response('Not found', { status: 404 })
return Response.json(article)
}Pattern 2 — Service repository (encapsulation)
Pour des règles métier au-dessus du repo CRUD pur :
import { createRepository, type Repository } from '@mostajs/repository'
interface Reservation { id: string; sessionId: string; userId: string; reservedAt: number }
export class ReservationService {
constructor(private repo: Repository<Reservation>) {}
async reserve(sessionId: string, userId: string): Promise<Reservation> {
const existing = await this.repo.findOne({ sessionId, userId })
if (existing) throw new Error('Already reserved')
const r: Reservation = {
id: crypto.randomUUID(),
sessionId,
userId,
reservedAt: Date.now(),
}
return this.repo.save(r)
}
async listForUser(userId: string) {
return this.repo.find({ userId }, { sort: { reservedAt: -1 } })
}
}
export const reservationService = new ReservationService(
createRepository<Reservation>({ collection: 'reservations' }),
)Pattern 3 — Repository sub-export d'un module @mostajs/*
Si tu publies un module @mostajs/X qui veut exposer un repo prêt-à-l'emploi :
// @mostajs/X/src/repository.ts
import { createRepository, type Repository } from '@mostajs/repository'
import type { MyEntity } from './types.js'
export function createXRepository(opts?: {
collection?: string
getPlug?: () => Promise<DataPlugLike>
}): Repository<MyEntity> {
return createRepository<MyEntity>({
collection: opts?.collection ?? 'x_entities',
getPlug: opts?.getPlug,
})
}Consumer :
import { createXRepository } from '@mostajs/X/repository'
const xRepo = createXRepository({ collection: 'tenant_xx_entities' })Pattern 4 — Custom data-plug (mocks, autre backend)
Le getPlug est injectable pour les tests ou un backend custom :
const mockPlug = {
findOne: jest.fn().mockResolvedValue({ id: 'abc', title: 'mock' }),
// ... autres méthodes
}
const repo = createRepository<Article>({
collection: 'articles',
getPlug: async () => mockPlug,
})
await repo.findById('abc')
expect(mockPlug.findOne).toHaveBeenCalledWith('articles', { id: 'abc' })Pattern 5 — Cache lecture (decorator custom)
function withMemoryCache<T extends Entity>(repo: Repository<T>, ttlMs = 60_000): Repository<T> {
const cache = new Map<string, { value: T; expiresAt: number }>()
return {
...repo,
async findById(id) {
const c = cache.get(id)
if (c && c.expiresAt > Date.now()) return c.value
const v = await repo.findById(id)
if (v) cache.set(id, { value: v, expiresAt: Date.now() + ttlMs })
return v
},
async save(e) {
const r = await repo.save(e)
cache.delete(e.id)
return r
},
async delete(id) {
const r = await repo.delete(id)
cache.delete(id)
return r
},
}
}6. Middlewares composables
withTenantScope(repo, { getTenantId, field? })
Injecte automatiquement tenantId (ou autre field) dans toutes les queries ET sur tous les save/upsert.
import { withTenantScope } from '@mostajs/repository'
import { AsyncLocalStorage } from 'node:async_hooks'
const tenantContext = new AsyncLocalStorage<string>()
const userRepo = withTenantScope(
createRepository<User>({ collection: 'users' }),
{ getTenantId: () => tenantContext.getStore() ?? null },
)
// Dans le handler HTTP, wrap chaque requête :
app.use((req, res, next) => {
const tenantId = extractTenantFromAuth(req)
tenantContext.run(tenantId, () => next())
})
// Tous les userRepo.find/findOne/save sont maintenant scoped automatiquement au tenant→ Utiliser @mostajs/multitenancy qui fournit getCurrentTenantId + middleware standard.
withSoftDelete(repo, { field? })
Transforme delete() en marquage deletedAt. Les queries excluent automatiquement les entités marquées :
const articles = withSoftDelete(createRepository<Article>({ collection: 'articles' }))
await articles.delete('art-001') // marque deletedAt, ne supprime pas réellement
await articles.findById('art-001') // null (filtré)
await articles.find() // exclu
// Pour voir les soft-deleted (admin, audit) :
await articles.find({ $deleted: true } as any) // bypass du filtrewithAuditTrail(repo, { getActor })
Injecte automatiquement les champs audit :
createdAt: number(set au premier save/upsert, jamais écrasé)createdBy: string | nullupdatedAt: number(set à chaque save/upsert/update)updatedBy: string | null
const articles = withAuditTrail(
createRepository<Article>({ collection: 'articles' }),
{ getActor: () => currentRequestUser?.id ?? 'system' },
)
await articles.save({ id: 'art-1', title: 'Hello', body: '...', authorId: 'u-1', publishedAt: Date.now(), tags: [] })
const saved = await articles.findById('art-1')
// {
// ...,
// createdAt: 1700000000000,
// createdBy: 'user-42',
// updatedAt: 1700000000000,
// updatedBy: 'user-42',
// }Composition
L'ordre des middlewares compte :
// ✅ Correct : audit le plus à l'extérieur (loggue avant tout autre filter)
const repo = withAuditTrail(
withSoftDelete(
withTenantScope(
createRepository<X>({ collection: 'x' }),
{ getTenantId },
),
),
{ getActor },
)
// ❌ Mauvais : tenant scope manqué si on chaine dans le mauvais sensRègle empirique : audit ⊃ soft-delete ⊃ tenant-scope ⊃ core (l'audit doit voir les opérations d'origine, le tenant scope doit toujours s'appliquer).
7. Patterns avancés
Pagination cursor-based
async function paginate<T extends Entity>(
repo: Repository<T>,
filter: Filter<T> = {},
cursor: { lastCreatedAt: number; lastId: string } | null,
pageSize = 20,
) {
const f = cursor
? { ...filter, createdAt: { $lt: cursor.lastCreatedAt } }
: filter
const items = await repo.find(f, { sort: { createdAt: -1, id: 1 }, limit: pageSize + 1 })
const hasNext = items.length > pageSize
const page = items.slice(0, pageSize)
return {
items: page,
nextCursor: hasNext ? {
lastCreatedAt: (page[page.length - 1] as any).createdAt,
lastId: page[page.length - 1].id,
} : null,
}
}Repository readonly (consultation seule)
function readonly<T extends Entity>(repo: Repository<T>): Pick<Repository<T>, 'findById' | 'findOne' | 'find' | 'count'> {
return {
findById: repo.findById.bind(repo),
findOne: repo.findOne.bind(repo),
find: repo.find.bind(repo),
count: repo.count.bind(repo),
}
}
export const publicArticleRepo = readonly(articleRepo) // exposé en read-only à l'app frontSharding manuel (gros volume)
function shardByHash<T extends Entity>(
baseCollection: string,
shardCount: number,
getKey: (id: string) => string,
): (id: string) => Repository<T> {
function shardOf(id: string): number {
const h = Array.from(getKey(id)).reduce((a, c) => a + c.charCodeAt(0), 0)
return h % shardCount
}
const shards = Array.from({ length: shardCount }, (_, i) =>
createRepository<T>({ collection: `${baseCollection}_${i}` }),
)
return (id) => shards[shardOf(id)]
}
const userShardLookup = shardByHash<User>('users', 4, (id) => id)
await userShardLookup('user-42').findById('user-42')8. Tests
// tests/article-service.test.ts
import { describe, it, expect } from 'vitest'
import { createMemoryRepository } from '@mostajs/repository'
import { ArticleService } from '../src/article-service'
describe('ArticleService', () => {
it('list articles by author', async () => {
const repo = createMemoryRepository<Article>({ collection: 'articles' })
const service = new ArticleService(repo)
await repo.save({ id: 'a1', authorId: 'u1', title: 'A', body: '', publishedAt: 1, tags: [] })
await repo.save({ id: 'a2', authorId: 'u1', title: 'B', body: '', publishedAt: 2, tags: [] })
await repo.save({ id: 'a3', authorId: 'u2', title: 'C', body: '', publishedAt: 3, tags: [] })
const arts = await service.listByAuthor('u1')
expect(arts).toHaveLength(2)
expect(arts.map(a => a.id)).toEqual(['a1', 'a2'])
})
})createMemoryRepository est idéal pour les tests unitaires : pas de DB, pas de Docker, exécution en ~ms.
9. Troubleshooting
@mostajs/data-plug not available
Error: [repository] @mostajs/data-plug not available. Install it or pass `getPlug` option.Cause : tu utilises createRepository mais le peer dep @mostajs/data-plug n'est pas installé. Solution : npm install @mostajs/data-plug.
Alternative : passer un getPlug custom :
createRepository({ collection: 'x', getPlug: async () => myCustomPlug })Tenant scoping non appliqué
Cause typique : le getTenantId() retourne null/undefined au moment de l'appel parce que le contexte async n'est pas propagé.
Solution : envelopper le handler dans tenantContext.run(tenantId, () => handler()) ; voir @mostajs/multitenancy qui fournit un middleware Express/Next prêt à l'emploi.
Soft-delete : entité supprimée toujours visible
Cause : le middleware n'est pas appliqué (createRepository sans wrap).
Solution : withSoftDelete(createRepository(...)) — l'ordre compte.
Performance : queries lentes sur Mongo
@mostajs/repository ne crée pas d'index : c'est responsabilité de l'app. Pour des queries fréquentes (e.g. find({ tenantId, status })), créer un index composé côté Mongo :
db.articles.createIndex({ tenantId: 1, status: 1, publishedAt: -1 })10. Modules liés
@mostajs/data-plug— backend storage abstraction (peer dep)@mostajs/multitenancy— TenantContext + middleware pourwithTenantScope@mostajs/config— env cascade pour configurerMOSTA_DATA@mostajs/booking— consumer concret (Slot + Reservation repos)@mostajs/media-sfu— consumer concret (LiveSession repo)
License : AGPL-3.0-or-later Auteur : Dr Hamid MADANI [email protected]
