npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 createRepository qui consomme @mostajs/data-plug (dispatch vers le bon backend selon MOSTA_DATA env)
  • Une implémentation createMemoryRepository in-memory pour tests/dev
  • 3 middlewares composables : withTenantScope, withSoftDelete, withAuditTrail
  • Une API minimaliste mais suffisante pour 90 % des CRUD applicatifs

Table des matières

  1. Pourquoi un repository générique ?
  2. Architecture
  3. Quick start — how to use
  4. API détaillée
  5. Implémentation — how to impl
  6. Middlewares composables
  7. Patterns avancés
  8. Tests
  9. Troubleshooting
  10. 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 REST

Avec 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é : createMemoryRepository pour 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-plug

Cas 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')
// → true

Backend 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 unitaires

Sortie 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 filtre

withAuditTrail(repo, { getActor })

Injecte automatiquement les champs audit :

  • createdAt: number (set au premier save/upsert, jamais écrasé)
  • createdBy: string | null
  • updatedAt: 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 sens

Rè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 front

Sharding 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


License : AGPL-3.0-or-later Auteur : Dr Hamid MADANI [email protected]