@roastery/pantry
v0.1.0
Published
Shared utilities, use cases, DTOs, and domain abstractions for the Roastery CMS ecosystem.
Maintainers
Readme
@roastery/pantry
Shared utilities, use cases, DTOs, and domain abstractions for the Roastery CMS ecosystem — reusable building blocks for entity CRUD, pagination, slug validation, and repository contracts.
Overview
pantry provides cross-cutting application and domain concerns shared across Roastery services:
- Repository contracts — Granular
ICan*capability interfaces plus theIEntityReader/IEntityWriter/IEntityRepositoryaggregates, so a repository declares exactly the surface it exposes. - Use cases — Generic, entity-agnostic CRUD orchestration: find by id-or-slug, list a page, create (plain and slug-guarded), update, and delete.
- Factories —
make*helpers that wire a use case (or the slug checker) to its dependencies and hand back a ready instance. - SlugUniquenessCheckerService — Domain service that verifies slug availability.
- DTOs & utilities — Schema-validated Data Transfer Objects for common query parameters, plus response-map and pagination helpers.
- Constants — Shared configuration values for pagination limits and cache expiration.
Technologies
| Tool | Purpose |
|------|---------|
| @roastery/terroir | Schema validation, exception hierarchy, and TypeBox re-exports |
| @roastery/beans | Entity abstractions, UUID utilities, and collection schemas |
| @roastery/barista | HTTP status codes and exception-to-status mapping |
| tsup | Bundling to ESM + CJS with .d.ts generation |
| Bun | Runtime, test runner, and package manager |
| Knip | Unused exports and dependency detection |
| Husky + commitlint | Git hooks and conventional commit enforcement |
Installation
Install the package and its peer dependencies:
bun add @roastery/pantry @roastery/terroir @roastery/beans @roastery/barista typescriptEvery export ships with full TSDoc, so signatures, parameters, and examples surface directly in your editor on hover.
Local development (link)
If you're developing pantry alongside another project, you can link it locally:
# Inside the pantry directory
bun run setup # builds and registers the link
# Inside your consuming project
bun link @roastery/pantryRepository contracts
Granular capability interfaces let a repository declare exactly the operations it supports — implement only what you need, and use cases depend on the narrowest contract that does the job. Every contract is generic over a single EntityType (which must extend IEntity<t.TSchema>), except ICanFindBy, which derives a second SchemaType generic from the entity, and ICanCount, which takes none.
import type {
ICanCreate,
ICanFind,
ICanFindBy,
ICanFindMany,
ICanFindManyByIds,
ICanUpdate,
ICanDelete,
ICanCount,
} from "@roastery/pantry/domain/types/repositories";| Contract | Method | Description |
|----------|--------|-------------|
| ICanCreate<E> | create(data: E): Promise<void> | Persists a newly built entity. |
| ICanFind<E> | find(id: string): Promise<E \| null> | Fetches a single entity by its UUID. |
| ICanFindBy<E, S?> | findBy(property, value): Promise<E \| null> | Fetches a single entity by any schema property. S (the entity's schema) is derived automatically. |
| ICanFindMany<E> | findMany(page: number): Promise<E[]> | Fetches one 1-based page; page size is fixed at MAX_ITEMS_PER_QUERY. |
| ICanFindManyByIds<E> | findManyByIds(ids: string[]): Promise<Array<E \| null>> | Batch lookup preserving order: same length/positions as ids, with null for every id that has no match. |
| ICanUpdate<E> | update(data: E): Promise<void> | Persists changes to an existing entity. |
| ICanDelete<E> | delete(data: E): Promise<void> | Removes an entity. |
| ICanCount | count(): Promise<number> | Reports the total number of stored entities. |
Aggregates
When a repository needs a broad surface, compose the aggregates instead of listing every ICan* by hand:
import type {
IEntityReader,
IEntityWriter,
IEntityRepository,
} from "@roastery/pantry/domain/types/repositories";| Aggregate | Combines |
|-----------|----------|
| IEntityReader<E> | ICanFind + ICanFindBy + ICanFindMany + ICanFindManyByIds + ICanCount |
| IEntityWriter<E> | ICanCreate + ICanUpdate + ICanDelete |
| IEntityRepository<E> | IEntityReader + IEntityWriter (full CRUD) |
Capsules typically extend IEntityRepository with their entity-specific reads (e.g. findManyByType), keeping the common surface inherited from here.
Use cases
Generic, entity-agnostic orchestration. Each use case depends on the narrowest repository contract it needs and exposes a single run method.
FindEntityByTypeUseCase
Resolves an entity from a repository by either its UUID or its slug, auto-detecting the input via detectEntry and delegating to find or findBy. Throws ResourceNotFoundException when nothing matches.
import { FindEntityByTypeUseCase } from "@roastery/pantry/application/use-cases";
import type { ICanFind, ICanFindBy } from "@roastery/pantry/domain/types/repositories";
class PostRepository implements ICanFind<Post>, ICanFindBy<Post> {
async find(id: string): Promise<Post | null> { /* ... */ }
async findBy(property, value): Promise<Post | null> { /* ... */ }
}
const useCase = new FindEntityByTypeUseCase(new PostRepository());
// Resolves by UUID
await useCase.run("550e8400-e29b-41d4-a716-446655440000", "posts");
// Resolves by slug
await useCase.run("my-first-post", "posts");
// Throws ResourceNotFoundException if not foundFindManyEntitiesUseCase
Lists a single page of entities alongside the pagination counters. totalPages is derived from the repository's raw count via getNumberOfPages.
import { FindManyEntitiesUseCase } from "@roastery/pantry/application/use-cases";
const useCase = new FindManyEntitiesUseCase(repository); // ICanFindMany & ICanCount
const { value, count, totalPages } = await useCase.run(1);
// value: Post[] · count: number · totalPages: numberCreateEntityUseCase
Builds an entity from its create input via an EntityFactory and persists it.
import { CreateEntityUseCase } from "@roastery/pantry/application/use-cases";
const useCase = new CreateEntityUseCase(repository, entityFactory); // ICanCreate + EntityFactory
const post = await useCase.run({ title: "Hello", slug: "hello" });CreateSluggedEntityUseCase
Same as CreateEntityUseCase, but for slug-bearing entities: it rejects the input with ResourceAlreadyExistsException when the built entity's slug is already taken.
import { CreateSluggedEntityUseCase } from "@roastery/pantry/application/use-cases";
const useCase = new CreateSluggedEntityUseCase(
repository, // ICanCreate
slugUniquenessChecker, // SlugUniquenessCheckerService
entityFactory, // EntityFactory
);
const post = await useCase.run({ title: "Hello", slug: "hello" });
// Throws ResourceAlreadyExistsException if the slug already existsUpdateEntityUseCase
Resolves an entity by UUID or slug, applies a partial set of field updates through EntityUpdater, and persists the rebuilt entity. Throws ResourceNotFoundException when no record matches.
import { FindEntityByTypeUseCase, UpdateEntityUseCase } from "@roastery/pantry/application/use-cases";
const findEntityByType = new FindEntityByTypeUseCase(repository); // ICanFind & ICanFindBy
const useCase = new UpdateEntityUseCase(repository, findEntityByType); // ICanUpdate
const updated = await useCase.run("550e8400-e29b-41d4-a716-446655440000", { title: "New title" }, "posts");DeleteEntityUseCase
Resolves the entity by UUID or slug via FindEntityByTypeUseCase, then deletes it. Throws ResourceNotFoundException when no record matches.
import { DeleteEntityUseCase, FindEntityByTypeUseCase } from "@roastery/pantry/application/use-cases";
const findEntityByType = new FindEntityByTypeUseCase(repository); // ICanFind & ICanFindBy
const useCase = new DeleteEntityUseCase(repository, findEntityByType); // ICanDelete
await useCase.run("550e8400-e29b-41d4-a716-446655440000", "posts");Factories
Each use case (and the slug checker) has a matching make* factory that wires its dependencies and returns a ready-to-use instance — handy when composing capsules at the infrastructure layer.
import {
makeCreateEntityUseCase,
makeCreateSluggedEntityUseCase,
makeFindEntityByTypeUseCase,
makeFindManyEntitiesUseCase,
makeUpdateEntityUseCase,
makeDeleteEntityUseCase,
} from "@roastery/pantry/infra/factories/application/use-cases";
import { makeSlugUniquenessCheckerService } from "@roastery/pantry/infra/factories/domain/services";
const create = makeCreateEntityUseCase(repository, entityFactory);
const list = makeFindManyEntitiesUseCase(repository);
const checker = makeSlugUniquenessCheckerService(reader);Every factory mirrors the constructor of the class it builds: pass the same dependencies you would hand the new expression, and receive the fully typed instance back.
EntityFactory
A factory type that builds a fully-formed domain entity from its create input. Use cases such as CreateEntityUseCase accept one so they never need to know how a concrete entity is constructed. Its second generic, InputType, is the shape of that input — whatever your create DTO defines, independent of the entity's own schema.
import type { EntityFactory } from "@roastery/pantry/domain/types";
// (data, initialProperties?) => Entity
const createPost: EntityFactory<Post, t.Static<typeof CreatePostDTO>> = (data, initial) =>
new Post(data, initial);
// Generates id / createdAt / updatedAt itself:
const post = createPost({ title: "Hello", slug: "hello" });
// Or preserves base fields when rebuilding a persisted entity:
const rebuilt = createPost({ title: "Hello", slug: "hello" }, { id, createdAt, updatedAt });data is the create input (the InputType generic) — whatever shape your create DTO defines. The optional initialProperties supplies the base entity fields (id / createdAt / updatedAt) when you need to preserve them (e.g. rebuilding a persisted entity) instead of generating fresh ones.
SlugUniquenessCheckerService
Domain service that checks whether a slug is still available. It reads through any ICanFindBy source, so it is generic over a single EntityType.
import { SlugUniquenessCheckerService } from "@roastery/pantry/domain/services";
const checker = new SlugUniquenessCheckerService(reader); // ICanFindBy source
await checker.run("my-post"); // true (unique — no entity uses it)
await checker.run("existing-post"); // false (already taken)DTOs
Schema-validated Data Transfer Objects for common query parameters.
IdOrSlugDTO
Accepts either a UUID or a slug under the id-or-slug key:
import { IdOrSlugDTO } from "@roastery/pantry/presentation/dtos";
// { "id-or-slug": "550e8400-e29b-41d4-a716-446655440000" }
// { "id-or-slug": "my-cool-post" }PaginationDTO
A 1-based page parameter (minimum 1, defaults to 1):
import { PaginationDTO } from "@roastery/pantry/presentation/dtos";
// { page: 1 }baristaResponse
Builds the HTTP-status-to-schema response map used to document a Barista route handler. It merges your explicit success/error schemas with auto-generated error payloads for the exceptions a route may raise, resolving each exception to its corresponding HTTP status.
import { baristaResponse } from "@roastery/pantry/presentation/utils";
const responses = baristaResponse(
{ 200: PostSchema },
{ domain: ["ResourceNotFoundException"] },
);
// {
// 200: PostSchema,
// 404: <generated ResourceNotFoundException schema>,
// }Status keys may be numeric (200) or names ("OK", "NotFound"); names are normalized to codes via HTTP_STATUS.
generateExceptionDTO
Factory that builds the schema describing the standard exception-response payload — message, name, source, and layer — pre-filling the example fields so generated API docs reflect the specific exception.
import { generateExceptionDTO } from "@roastery/pantry/presentation/dtos/utils";
const schema = generateExceptionDTO("ResourceNotFoundException", "domain");Utilities
detectEntry
Detects whether a string is a UUID or a slug (via UuidSchema.match from @roastery/beans):
import { detectEntry } from "@roastery/pantry/application/utils";
detectEntry("550e8400-e29b-41d4-a716-446655440000"); // "UUID"
detectEntry("my-cool-post"); // "SLUG"getNumberOfPages
Computes how many pages are needed to paginate a collection. itemsPerPage defaults to MAX_ITEMS_PER_QUERY. Returns 0 when there is nothing to paginate.
import { getNumberOfPages } from "@roastery/pantry/application/utils";
getNumberOfPages(100, 10); // 10
getNumberOfPages(101, 10); // 11
getNumberOfPages(100); // 8 (default: 14 items per page)
getNumberOfPages(0, 10); // 0Constants
import { MAX_ITEMS_PER_QUERY, CACHE_EXPIRATION_TIME } from "@roastery/pantry/constants";| Constant | Value | Description |
|----------|-------|-------------|
| MAX_ITEMS_PER_QUERY | 14 | Default items per page for pagination |
| CACHE_EXPIRATION_TIME.SAFE | 3600 | 1 hour (seconds) |
| CACHE_EXPIRATION_TIME.LOW_UPDATES | 86400 | 24 hours (seconds) |
| CACHE_EXPIRATION_TIME.HIGH_UPDATES | 900 | 15 minutes (seconds) |
Shared types
Application-layer types re-exported from a single barrel:
import type { ValueTypes, EntryActions, ICountItems } from "@roastery/pantry/application/types";| Type | Description |
|------|-------------|
| ValueTypes | "UUID" \| "SLUG" — discriminates whether an identifier is a UUID or a slug. |
| EntryActions<T> | Record<ValueTypes, () => T> — maps each entry kind to a handler producing a T. |
| ICountItems | { totalPages: number; count: number } — pagination counters returned by list queries. |
Exports reference
// Application — Use Cases
import {
FindEntityByTypeUseCase,
FindManyEntitiesUseCase,
CreateEntityUseCase,
CreateSluggedEntityUseCase,
UpdateEntityUseCase,
DeleteEntityUseCase,
} from "@roastery/pantry/application/use-cases";
// Application — Types
import type { ValueTypes, EntryActions, ICountItems } from "@roastery/pantry/application/types";
// Application — Utilities
import { detectEntry, getNumberOfPages } from "@roastery/pantry/application/utils";
// Domain — Services
import { SlugUniquenessCheckerService } from "@roastery/pantry/domain/services";
// Domain — Types
import type { EntityFactory } from "@roastery/pantry/domain/types";
// Domain — Repository Contracts
import type {
ICanCreate,
ICanFind,
ICanFindBy,
ICanFindMany,
ICanFindManyByIds,
ICanUpdate,
ICanDelete,
ICanCount,
IEntityReader,
IEntityWriter,
IEntityRepository,
} from "@roastery/pantry/domain/types/repositories";
// Presentation — DTOs
import { IdOrSlugDTO, PaginationDTO } from "@roastery/pantry/presentation/dtos";
// Presentation — DTO Utilities
import { generateExceptionDTO } from "@roastery/pantry/presentation/dtos/utils";
// Presentation — Utilities
import { baristaResponse } from "@roastery/pantry/presentation/utils";
// Constants
import { MAX_ITEMS_PER_QUERY, CACHE_EXPIRATION_TIME } from "@roastery/pantry/constants";
// Infra — Application Use-Case Factories
import {
makeCreateEntityUseCase,
makeCreateSluggedEntityUseCase,
makeFindEntityByTypeUseCase,
makeFindManyEntitiesUseCase,
makeUpdateEntityUseCase,
makeDeleteEntityUseCase,
} from "@roastery/pantry/infra/factories/application/use-cases";
// Infra — Domain Service Factories
import { makeSlugUniquenessCheckerService } from "@roastery/pantry/infra/factories/domain/services";Development
# Run tests
bun run test:unit
# Run tests with coverage
bun run test:coverage
# Build for distribution
bun run build
# Check for unused exports and dependencies
bun run knip
# Full setup (build + bun link)
bun run setupChangelog
See CHANGELOG.md for the release history.
License
MIT
