@di-framework/repo
v5.3.7
Published
A coherent abstraction of repositories and storage adapters for di-framework.
Readme
@di-framework/repo
A coherent abstraction of repositories and storage adapters for TypeScript, with optional integration for di-framework-core.
Features
- Storage Agnostic: Decouples your business logic from the underlying storage technology (SQL, NoSQL, In-Memory, etc.).
- Standardized Patterns: Provides
BaseRepository,EntityRepository, andSoftDeleteRepositoryto handle common data access patterns. - Built-in Pagination: Standardized
PageandPaginatedResulttypes with built-in support in adapters and repositories. - In-Memory Implementation: Includes a fully functional
InMemoryRepositoryfor prototyping and testing. - DI Integration: Seamlessly integrates with
di-framework-corevia the@Repositorydecorator. - Models: Optional Spring/JPA-style
@Model,@Id, and@GeneratedValuefor multi-context identity metadata.
Installation
bun add @di-framework/repoRequired for DI integration: If you want to use the @Repository decorator for dependency injection, install the DI framework peer dependency.
bun add @di-framework/coreImportant: Always import from the scoped package name @di-framework/core/*.
Mixing different import IDs (e.g., di-framework/* or relative paths to sources) can load a second copy of the library and create a second global container instance.
Correct:
import { useContainer } from '@di-framework/core/container';
import { Container, Component } from '@di-framework/core/decorators';Avoid:
import { useContainer } from 'di-framework/container'; // Wrong: unscoped id
import { Container } from '../../di-framework/decorators'; // Wrong: relative idBasic Usage
1. Define your Model
Use @Model on a class. Mark identity fields with @Id and optional @GeneratedValue (stacked like Spring/JPA). The class is the TypeScript type — no separate interface is required. Storage can still use plain objects assignable to that shape.
import {
GeneratedValue,
GenerationType,
Id,
IdKind,
Model,
} from '@di-framework/repo';
@Model()
class User {
@Id()
@GeneratedValue({ strategy: GenerationType.Identity })
id!: number;
@Id({ kind: IdKind.Public })
@GeneratedValue({ strategy: GenerationType.UUID }) // UUIDv7 in this framework
publicId!: string;
name!: string;
email!: string;
}Identity contexts (IdKind)
A model may have several identity fields — each identifies the record in a different context:
| Kind | Typical field | Role |
| --- | --- | --- |
| Primary (default) | id | Database / repository primary key. Several Primary fields = composite PK. |
| Public | publicId | Safe id for URLs / APIs (often UUIDv7). |
| External | stripeCustomerId | Id assigned by another system. |
| Legacy | legacyId | Retained during migration. |
| Tenant | tenantId | Owning org / customer (may also be part of a composite PK). |
| Version | versionId | Particular revision. |
@Id is for identities of this model. Foreign keys to other models are not IdKinds (relations stay separate).
Generation (GenerationType)
Aligned with Jakarta Persistence GenerationType: Auto, Identity, Sequence, Table, UUID.
@GeneratedValuemust be stacked with@Idon the same property.- Default strategy when
@GeneratedValue()is used without options isAuto. GenerationType.UUIDmeans UUIDv7 (RFC 9562 time-ordered), not random v4 — intentional default for index-friendly keys.
Metadata is optional for repositories today — use getModelMetadata / getIdentities / getPrimaryId / isModel when adapters need it. Repositories still take an explicit id type parameter (InMemoryRepository<User, number>).
Plain interfaces still work if you prefer them:
interface User {
id: number;
name: string;
email: string;
}2. Implement a Repository
You can extend InMemoryRepository for quick prototyping:
import { InMemoryRepository } from '@di-framework/repo';
class UserRepository extends InMemoryRepository<User, number> {
async findByEmail(email: string): Promise<User | null> {
const all = await this.findAll();
return all.find((u) => u.email === email) || null;
}
}3. Use with di-framework
Use the @Repository decorator to automatically register your repository with the di-framework-core container.
import {
GeneratedValue,
GenerationType,
Id,
Model,
Repository,
InMemoryRepository,
} from '@di-framework/repo';
@Model()
class User {
@Id()
@GeneratedValue({ strategy: GenerationType.Identity })
id!: number;
name!: string;
email!: string;
}
@Repository()
class UserRepository extends InMemoryRepository<User, number> {
// ...
}
// In another service
@Container()
class UserService {
constructor(@Component(UserRepository) private users: UserRepository) {}
async listUsers() {
return this.users.findAll();
}
}Storage Adapters
The StorageAdapter interface allows you to implement custom backends.
import { StorageAdapter, BaseRepository } from '@di-framework/repo';
class MyCustomAdapter<E, ID> implements StorageAdapter<E, ID> {
// Implement findById, save, delete, findPaginated, etc.
}
class MyRepository extends BaseRepository<User, number> {
constructor(adapter: MyCustomAdapter<User, number>) {
super(adapter);
}
}API Overview
Repository Classes
BaseRepository<E, ID>: The foundational repository class.EntityRepository<E, ID>: Standard entity-aware repository.SoftDeleteRepository<E, ID>: Repository with soft-delete capabilities.InMemoryRepository<E, ID>: Ready-to-use in-memory implementation.
Decorators
@Model(): Marks a class as a domain data model.@Id(options?): Marks an identity field (kind?: IdKind, defaultPrimary).@GeneratedValue(options?): Stacked with@Id;strategy?: GenerationType(defaultAuto). UUID ⇒ UUIDv7.@Repository(options): Registers the class as a singleton indi-framework-core.
Identity helpers
IdKind/GenerationType: const objects + string unions.getModelMetadata/getIdentities/getPrimaryId/isModel.
Types
StorageAdapter<E, ID>: Interface for storage implementations.Page<T>/PaginatedResult<T>: Standardized pagination metadata.EntityId: Type alias forstring | number.
Durable SQL adapters
BunSqliteAdapter and D1Adapter implement the complete StorageAdapter contract over an existing SQLite-compatible table. The adapter intentionally does not run migrations: create the table in your application and provide an explicit table (plus idColumn, entityToRow, and rowToEntity when needed).
const users = new BunSqliteAdapter(db, { table: 'users', idColumn: 'id' });
await users.findPaginated({ page: 1, size: 20, sort: 'name:asc', filter: { active: true } });Cloudflare D1 uses the same options and binding API. D1 transactions are scoped to the callback and D1's platform batch/statement limits apply; schema changes and migrations remain out of band. Bun adapters close their database from dispose(); D1 disposal is a no-op.
Conditional Writes & Atomic Operations
Adapters may optionally implement ConditionalStorageAdapter<E, ID> to support atomic conditional write capabilities:
saveIfAbsent(entity: E): Promise<boolean>: Insertsentityonly if no record with its ID currently exists. Returnstrueif inserted, orfalseif a record with the same ID already exists. Built-in SQL adapters useINSERT INTO ... ON CONFLICT (...) DO NOTHINGand check for positive affected rows.compareAndSwap(id: ID, mutate: (current: E | null) => E | null): Promise<boolean>: Executes a synchronous, side-effect-freemutatefunction atomically against the current state atid. Ifmutatereturnsnull(condition failed or abort requested), the operation aborts without writing and returnsfalse. Ifmutatereturns a non-null entity, it updates the record and returnstrue.
Use the exported type guard supportsConditionalWrite(adapter) to detect capability support at runtime:
import { supportsConditionalWrite } from '@di-framework/repo';
if (supportsConditionalWrite(adapter)) {
const inserted = await adapter.saveIfAbsent(entity);
const swapped = await adapter.compareAndSwap(id, (current) => {
if (!current || current.version !== expectedVersion) return null;
return { ...current, version: current.version + 1 };
});
}Built-in adapters (InMemoryRepository, SqlStorageAdapter, BunSqliteAdapter, D1Adapter) implement ConditionalStorageAdapter. Custom StorageAdapter implementations do not require source changes unless conditional write capability is desired.
Blob Storage Primitives & S3 Adapters
For binary assets, large files, media uploads, and documents, @di-framework/repo provides first-class Blob storage primitives:
BaseBlobRepository: Abstract base class for type-safe binary asset repositories.BlobStorageAdapter: Contract for blob storage engines.InMemoryBlobStorageAdapter: In-memory implementation with full pagination and delimiter support for testing.S3BlobStorageAdapter: Production adapter supporting AWS S3, Cloudflare R2, MinIO, Wasabi, and S3-compatible APIs, including automatic multipart uploads for large files and presigned URLs.
Usage Example
import {
BaseBlobRepository,
InMemoryBlobStorageAdapter,
S3BlobStorageAdapter,
} from '@di-framework/repo';
class MediaRepository extends BaseBlobRepository {
constructor() {
super(
new S3BlobStorageAdapter({
bucket: 'my-app-media',
region: 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
}),
);
}
}
const mediaRepo = new MediaRepository();
// Put file (supports string, Buffer, Uint8Array, ReadableStream, Blob)
await mediaRepo.put('uploads/doc.pdf', fileStream, {
contentType: 'application/pdf',
customMetadata: { userId: '123' },
});
// Read file
const blob = await mediaRepo.get('uploads/doc.pdf');
if (blob) {
console.log(blob.metadata.size);
const text = await blob.text();
}
// Generate presigned URL
const signedUrl = await mediaRepo.getSignedUrl('uploads/doc.pdf', {
operation: 'get',
expiresInSeconds: 3600,
});
// List with pagination and prefix
const results = await mediaRepo.list({ prefix: 'uploads/', delimiter: '/' });Database Migrations
@di-framework/repo provides database schema migrations supporting both decorator-based definitions and SQL/manifest-based discovery:
@Migration({ version, description, binding }): Class decorator defining migrations with dependency-injected execution context.- Manifest and SQL Discovery: Discovers
.sqlmigration files with standard version headers (-- migration:version,-- migration:description, etc.) or manifest files (migrations.json). - Shared Runner (
MigrationRunner):- DB-backed locking: Serializes concurrent migration attempts using a lock table (
_migrations_lock). - History tracking: Records applied versions, checksums, timestamps, and execution times in
_migrations. - Version ordering: Applies pending migrations sequentially in ascending version order.
- Integrity validation: Validates applied migration checksums against current definitions and rejects out-of-order pending migrations.
- Dev auto-apply: Automatically applies pending migrations in development/test environments.
- DB-backed locking: Serializes concurrent migration attempts using a lock table (
Example: Decorator Migration
import { Migration, type MigrationExecutionContext } from '@di-framework/repo';
@Migration({
version: 1,
description: 'create users table',
binding: 'default',
})
export class CreateUsersTable {
async up(ctx: MigrationExecutionContext): Promise<void> {
await ctx.sql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)');
}
async down(ctx: MigrationExecutionContext): Promise<void> {
await ctx.sql('DROP TABLE users');
}
}CLI Commands
# Check status of applied and pending migrations
di-framework migrations status --db ./dev.db --dir ./migrations
# Execute pending migrations
di-framework migrations execute --db ./dev.db --dir ./migrations
# Dry run / preview plan
di-framework migrations execute --db ./dev.db --dir ./migrations --dry-run
# Output stable JSON envelope
di-framework migrations status --db ./dev.db --json