typeorm-scoped-repository
v0.3.0
Published
TypeORM ScopedRepository with fortress pattern — automatic multi-scope isolation (organisation, user, agent, tenant) on every query. Framework-agnostic.
Maintainers
Readme
typeorm-scoped-repository
TypeORM ScopedRepository with fortress pattern for NestJS — automatic multi-scope isolation on every query. No raw SQL, no forgotten WHERE clauses, no cross-tenant data leaks.
Features
- Automatic scope injection — every
find,findOne,save,update,deletegets the scope fields added as WHERE conditions - Fortress pattern —
.where()is silently converted to.andWhere()on query builders, so scope filters cannot be accidentally cleared - Composite scopes — scope on any combination of fields (
organisationId,userId + agentId,accountId + ownerSpace, etc.) - Layered scoping — add scope fields progressively with
withScope() - Transaction support —
withTransaction(manager)maintains scope inside transactions - TypeScript-first, zero magic
Install
npm install typeorm-scoped-repositoryUsage
Single scope (organisation isolation)
import { ScopedRepository } from 'typeorm-scoped-repository';
@Injectable()
export class ArticleService {
constructor(
@InjectRepository(ArticleEntity)
private readonly _repo: Repository<ArticleEntity>,
) {}
private repo(organisationId: string) {
return new ScopedRepository(this._repo, { organisationId });
}
async findAll(organisationId: string) {
return this.repo(organisationId).find();
// Executes: SELECT * FROM articles WHERE organisation_id = $1
}
async create(organisationId: string, data: Partial<ArticleEntity>) {
return this.repo(organisationId).save(data as ArticleEntity);
// Always stamps organisationId — cannot forget it
}
}Composite scope (multi-field isolation)
const agentSpace = md5(`${userId}:${agentId}`).slice(0, 12);
const repo = new ScopedRepository(contextVectorRepo, {
accountId: 'default',
ownerSpace: agentSpace,
});
// All queries: WHERE account_id = 'default' AND owner_space = 'a3f9c2b1e8d4'
const records = await repo.find();Layered scoping
// Base scope — account level
const accountRepo = new ScopedRepository(repo, { accountId: 'acme' });
// Add agent scope on top
const agentRepo = accountRepo.withScope({ ownerSpace: '10f5d88f294c' });
// agentRepo: WHERE account_id = 'acme' AND owner_space = '10f5d88f294c'
// accountRepo unchanged: WHERE account_id = 'acme'Fortress pattern (query builder)
const qb = repo.createQueryBuilder('entity');
// Scope already applied: WHERE entity.organisation_id = 'org-123'
qb.where('entity.name = :name', { name: 'test' });
// Safe! .where() is converted to .andWhere() — scope NOT cleared
// Final: WHERE entity.organisation_id = 'org-123' AND entity.name = 'test'Idempotent insert (ON CONFLICT DO NOTHING)
save() uses TypeORM's upsert-style Repository.save() — a retried call updates the existing row. When you need true idempotency (retried call is a no-op, never an update), use insert() with orIgnore:
await repo.insert(entity, { orIgnore: true });
// PostgreSQL: INSERT … ON CONFLICT DO NOTHING
// SQLite: INSERT OR IGNORE …Scope is stamped into the VALUES payload, so a retried worker cannot accidentally land the row in a different scope. Bulk inserts (insert([e1, e2, ...])) stamp scope on every row.
Transactions
await dataSource.transaction(async (manager) => {
const txRepo = myRepo.withTransaction(manager);
// Same scope, within the transaction
await txRepo.save(entity);
});withTransaction(manager, EntityClass?) returns a fresh scoped repo bound to the transaction's EntityManager. The fortress is preserved across the boundary: queries built off this repo (find, update, insert, createQueryBuilder('alias').setLock(...), …) still auto-inject scope.
Pass the entity class as the second argument if the underlying repo's target cannot be inferred (typically only happens in tests with hand-rolled mocks).
Recommended pattern (NestJS)
The shape below is the one this package was designed for. Consumer codebases that have followed it have stayed bypass-free; codebases that diverged from it have rediscovered every fortress hole the package was built to close.
// 1. Feature module — register entities through the package's helper
@Module({
imports: [ScopedRepositoryModule.forFeature([ArticleEntity, CategoryEntity])],
providers: [ArticleService],
})
export class ArticleModule {}// 2. Repository class — inject the FACTORY, never @InjectRepository
@Injectable()
export class ArticleRepository {
constructor(
@InjectScopedFactory(ArticleEntity)
private readonly articles: ScopedRepositoryFactory<ArticleEntity>,
) {}
// Methods take (scope, …args, em?: EntityManager) so they compose with
// caller-owned transactions. Never store scope in the class.
async findById(scope: Scope, id: string) {
return this.articles(scope).findOne({ where: { id } });
}
async insertOrIgnore(scope: Scope, article: Article, em: EntityManager) {
await this.articles(scope)
.withTransaction(em, ArticleEntity)
.insert(article, { orIgnore: true });
}
async findForUpdate(scope: Scope, ids: string[], em: EntityManager) {
return this.articles(scope)
.withTransaction(em, ArticleEntity)
.createQueryBuilder('a')
.where('a.id = ANY(:ids)', { ids: [...ids].sort() })
.setLock('pessimistic_write')
.getMany();
}
}// 3. Service / use-case — OWNS the transaction boundary
@Injectable()
export class ArchiveService {
constructor(
private readonly dataSource: DataSource,
private readonly articleRepo: ArticleRepository,
private readonly categoryRepo: CategoryRepository,
) {}
async archiveCategory(scope: Scope, categoryId: string) {
return this.dataSource.transaction(async (em) => {
const locked = await this.articleRepo.findForUpdate(scope, [categoryId], em);
await this.categoryRepo.updateStatus(scope, categoryId, 'archived', em);
// Both repos participate in the same transaction with scope intact.
});
}
}Anti-patterns checklist
Treat each of these as a code-review red flag.
| ❌ Anti-pattern | ✅ Correct shape |
|---|---|
| @InjectRepository(Entity) for a scoped entity (factory and shadow injected together) | @InjectScopedFactory(Entity) only |
| Manual scope stamping: (entity as X).orgId = orgId before insert | Let scopedRepo.insert(entity) / .save(entity) stamp scope automatically |
| Raw em.createQueryBuilder(Entity, 'alias') inside a caller-owned transaction | scopedRepo.withTransaction(em, Entity).createQueryBuilder('alias') |
| get manager(): EntityManager { return this.repo.manager; } exposed publicly | Use scopedRepo.transaction(cb) for self-opened transactions |
| this.repo.manager.transaction(...) to open a transaction inside a repository | scopedRepo.transaction(async (txRepo, em) => …) |
| Service-owned scope state: class FooService { constructor() { this.repo = … } } storing scope in the class | Pass scope through method signatures; never cache it in a class |
| INSERT … ON CONFLICT DO NOTHING written by hand as a QueryBuilder chain | scopedRepo.insert(entity, { orIgnore: true }) |
| repo.where(…) rewritten to add an explicit scope clause | Trust the fortress: .where(…) is silently converted to .andWhere(…) so scope cannot be cleared |
When ReflectiveAI-shaped exceptions are legitimate
These patterns look like anti-patterns but are sometimes structurally required:
- Raw SQL via
dataSource.query(sql, params)for recursive CTEs. The fortress is a QueryBuilder wrapper; raw SQL is outside it by design. Bind scope as a numbered parameter ($3) at every WHERE clause that touches a scoped table. Document the carve-out at the top of the method. - Multi-transaction workflows where one transaction's writes must commit before another's reads. The service still owns each transaction boundary; just re-enter the fortress (
withTransaction(em)) inside every block. The two transactions stay separate; the bypasses don't.
See docs/nestjs.md for the full discussion and worked examples.
API
new ScopedRepository<T>(repo, scope)
| Parameter | Type | Description |
|---|---|---|
| repo | Repository<T> | TypeORM repository |
| scope | Scope | Record<string, string> of field names to values |
Methods
| Method | Description |
|---|---|
| find(options?) | Find all matching records (scope always applied) |
| findOne(options) | Find one record (scope always applied) |
| save(entity) | Save with scope fields stamped (upsert-style) |
| insert(entity, { orIgnore? }) | Plain INSERT with scope stamped; orIgnore for INSERT … ON CONFLICT DO NOTHING |
| create(data) | Create entity instance with scope fields |
| update(id, partial) | Update by id within scope |
| delete(id) | Delete by id within scope |
| count(options?) | Count within scope |
| createQueryBuilder(alias) | Fortress-wrapped query builder |
| withScope(additionalScope) | Returns new repo with extended scope |
| withTransaction(manager, entityClass?) | Returns new repo bound to an external transaction manager |
| transaction(cb) | Opens a new transaction; callback receives (scopedRepo, em) |
| getScope() | Returns a readonly copy of the current scope |
Motivation
This pattern was extracted from a production NestJS application with strict organisation-level data isolation. It proved robust enough to generalise for any multi-scope use case.
License
MIT
