@smartsoft001/crud-shell-app-services
One service that sits between a transport and a repository, and applies the same four rules to every record that passes: permission, validation, trimming, hashing.
Install
npm install @smartsoft001/crud-shell-app-services @smartsoft001/crud-domain @smartsoft001/crud-shell-dtos @smartsoft001/domain-core @smartsoft001/models @smartsoft001/nestjs @smartsoft001/users @smartsoft001/utils
The manifest declares the seven workspace packages above as peer dependencies, pinned to its own version. The rest has to be installed alongside it: @nestjs/common for @Injectable and Logger, rxjs for the change feed, guid-typescript for id generation, lodash-decorators for the memoised attachment lookup, and combined-stream for resumable uploads.
It never talks to a database
The service depends on the abstract IItemRepository and IAttachmentRepository from @smartsoft001/domain-core, never on a driver. Nothing here opens a connection, which is why the examples on this page run against an array in memory and the specs need no database.
What it is
The CRUD family keeps the rules in one place and the transports around it. CrudService is that place. A REST controller, a websocket gateway or a scheduled job all call the same methods, so a record reaches storage having passed the same checks no matter how it arrived.
Each write follows a fixed order. The permission service is asked whether the caller may perform the operation, and throws DomainForbiddenError if not. The service then turns the payload into an instance of the model class it was configured with, SharedConfig.type, carrying exactly the keys the payload carried. This step is what makes the two checks that follow see anything: castModel and getInvalidFields read the @Field metadata from the class prototype, so a plain object parsed from a JSON body has no fields to check, while an instance of the model has all of them. castModel then deletes every property the model does not open for that operation, so an unexpected field in a request body cannot be stored. getInvalidFields reports the required fields still empty after the trim, and a non-empty answer becomes a DomainValidationError whose message lists them. Only then is the password hashed and the record handed to the repository. Reads run the permission check and then delete password from whatever comes back.
Validation needs the model type
Without SharedConfig.type the service checks the payload as it arrives. A model instance built by application code is still validated, but a plain object from a request body passes through untouched, because it carries no field metadata. The service logs a warning at startup when the type is missing. CrudShellNestjsModule fills it from the db.type option, as described on the @smartsoft001/crud-shell-nestjs page.
The generic parameter is bounded by IEntity<string>, so any entity the service handles has an id, and the service assigns it: every insert gets a fresh GUID rather than trusting the one in the payload.
Usage
Construct it and create a record
import { CrudService } from '@smartsoft001/crud-shell-app-services';
import {
IAttachmentRepository,
IEntity,
IItemRepository,
} from '@smartsoft001/domain-core';
import { Field, Model } from '@smartsoft001/models';
import { PermissionService, SharedConfig } from '@smartsoft001/nestjs';
import { IUser } from '@smartsoft001/users';
@Model({ titleKey: 'title' })
export class Note implements IEntity<string> {
// Assigned by `CrudService.create`, which generates a GUID for every insert.
id!: string;
// `required` inside the `create` block applies to the create mode only.
@Field({ required: true, create: { required: true } })
title?: string;
// `confirm: true` makes the generated form render a second `passwordConfirm`
// control. That extra control is not part of the model, so `CrudService`
// never stores it. The password itself is hashed before it reaches the
// repository.
@Field({ confirm: true, create: true })
password?: string;
// Sent by the form, declared on no `@Field`, and therefore dropped on create.
passwordConfirm?: string;
}
/**
* Array-backed stand-in for the repository that `MongoModule.forRoot(...)`
* binds in a real application. It implements only the methods this example
* exercises, so it is cast to the full `IItemRepository` contract below.
*/
export class InMemoryNoteRepository {
readonly items: Note[] = [];
async create(item: Note): Promise<void> {
this.items.push(item);
}
async createMany(list: Note[]): Promise<void> {
this.items.push(...list);
}
async clear(): Promise<void> {
this.items.length = 0;
}
}
/** Wires `CrudService` by hand: permissions, item storage, attachments. */
export function createNoteService(
repository: InMemoryNoteRepository,
): CrudService<Note> {
const config: SharedConfig = {
permissions: {
create: ['admin'],
read: ['admin', 'user'],
update: ['admin'],
delete: ['admin'],
},
};
const attachmentRepository = {} as unknown as IAttachmentRepository<Note>;
return new CrudService<Note>(
new PermissionService(config),
repository as unknown as IItemRepository<Note>,
attachmentRepository,
);
}
/** Validates, hashes the password, stores the note and returns its new id. */
export function createNote(
service: CrudService<Note>,
note: Note,
user: IUser,
): Promise<string> {
return service.create(note, user);
}
The region does three things. It declares a Note model whose title is required on create and whose password is declared with confirm: true, so the generated form renders a second control the model itself never mentions. It defines an array-backed repository implementing only the three methods these examples reach. And it builds the service by hand, passing a real PermissionService over a literal SharedConfig, the fake repository, and an empty object standing in for the attachment repository.
The spec proves the four rules on one call. The id the method returns is the id of the stored note, so the generated GUID is what the caller gets back. The repository received exactly one create. The stored password equals PasswordService.hash('secret'), not the plain text. The stored record has no passwordConfirm key, because that property carries no @Field and castModel removed it. And a note whose title is blank makes the call reject with DomainValidationError('Required fields: title'), the exact message assembled from the invalid-field list.
The example builds the service without a SharedConfig and passes Note instances, so the metadata is already there. An application that receives plain JSON has to configure the type, otherwise the same blank title would be stored.
Import a batch
import { CreateManyMode, ICreateManyOptions } from '@smartsoft001/crud-domain';
import { CrudService } from '@smartsoft001/crud-shell-app-services';
import { IUser } from '@smartsoft001/users';
import { Note } from './crud-service.example';
/**
* Bulk insert. `mode: 'replace'` empties the collection before the insert, so
* the imported batch becomes the whole content. `mode: 'default'` appends to
* whatever is already stored.
*
* Every note is validated and its password hashed, exactly as in `create`, and
* the returned array carries the generated ids.
*/
export function importNotes(
service: CrudService<Note>,
notes: Note[],
user: IUser,
mode: CreateManyMode,
): Promise<Note[]> {
const options: ICreateManyOptions = { mode };
return service.createMany(notes, user, options);
}
createMany applies the same validation and hashing to every element, and adds the choice described by ICreateManyOptions from @smartsoft001/crud-domain. Its spec asserts that 'replace' calls clear on the repository before createMany, comparing the recorded invocation order, that 'default' never calls clear, and that each returned note carries a generated id.
API
Constructor
new CrudService<T>(permissionService, repository, attachmentRepository, config?). In an application all four arrive through the Nest injector; the module in @smartsoft001/crud-shell-nestjs binds them.
| Parameter | Type | Provided by |
|---|---|---|
permissionService | PermissionService | @smartsoft001/nestjs, configured by SharedModule. |
repository | IItemRepository<T> | @smartsoft001/mongo in a normal application. |
attachmentRepository | IAttachmentRepository<T> | The same package, backed by GridFS. |
config | SharedConfig | The same SharedModule, marked @Optional(). Its type is the model class every write payload is turned into before validation. Omit it and payloads are checked as given. |
All four are protected readonly, so a subclass can reach them. The conversion itself is the protected method toModel(data): it returns data unchanged when no type is configured or data is already an instance of it, and otherwise copies the payload onto a fresh instance and removes every key the payload did not carry, so class field initialisers never leak into a partial update.
Records
| Method | Returns | What it does |
|---|---|---|
create(data, user) | Promise<string> | Assigns a fresh GUID, checks the create permission, trims and validates for the create mode, hashes password, drops passwordConfirm, stores, returns the id. |
createMany(data, user, options) | Promise<T[]> | The same per element. With options.mode === 'replace' it clears the collection first. Returns the input array, now carrying ids. |
readById(id, user) | Promise<T> | Checks the read permission, fetches by id, deletes password from the result. |
read(criteria, options, user) | Promise<{ data: T[]; totalCount: number }> | Checks the read permission and queries by criteria. Deletes password from every row. |
readBySpec(spec, options, user) | Promise<{ data: T[]; totalCount: number }> | read with spec.criteria, for callers holding an ISpecification. |
update(id, data, user) | Promise<void> | A full replace. Forces data.id = id, checks the update permission, trims and validates for the update mode, hashes password, drops passwordConfirm. |
updatePartial(id, data, user) | Promise<void> | The same, except the required-field check only covers keys actually present on the payload. A payload that reaches the check as a plain object, which only happens without a configured type, skips validation entirely. |
delete(id, user) | Promise<void> | Checks the delete permission and removes the record. |
Every method wraps its body in a try/catch that logs through a Logger named after the class and rethrows unchanged, so a caller sees the original error and the server log keeps the stack.
Attachments
| Method | Returns | What it does |
|---|---|---|
uploadAttachment(data, options?) | Promise<string> | Generates an id when data.id is empty and uploads the stream. Returns the id the content was stored under. |
getAttachmentInfo(id) | Promise<{ fileName: string; contentType: string; length: number }> | File metadata. Decorated with @Memoize(), so the first answer for an id is cached on the instance for the life of the process. |
getAttachmentStream(id, options?) | Promise<Readable> | The content, optionally a byte range, which is what backs HTTP range requests. |
deleteAttachment(id) | Promise<void> | Removes the stored file. |
None of the four consults the permission service; access control for attachments is left to the route.
uploadAttachment also implements resuming an interrupted upload, and its behaviour there is worth reading closely. Given options.start, it fetches bytes 0 to start - 1 of the existing attachment, prepends them to the incoming stream through CombinedStream, generates a new id and uploads the joined stream under it. Two details follow from the code as written: the call to the repository's upload is not awaited, so the promise the method returns resolves before the write finishes, and the delete that runs afterwards is passed data.id, which by then holds the new id rather than the partial one.
The change feed
changes(criteria: { id?: string }): Observable<ItemChangedData> forwards repository.changesByCriteria. Passing an id narrows the feed to one record, and an empty object watches the whole collection. The emitted union is documented in @smartsoft001/crud-shell-dtos. Only a repository that implements change streams produces anything; MongoItemRepository does, by watching the collection.
SERVICES
SERVICES is [CrudService], the provider array a Nest module spreads into its providers and exports. CrudShellNestjsModule does exactly that, so an application normally registers the service by importing that module rather than by naming the class.
Related packages
@smartsoft001/crud-domaindefines the options objectcreateManytakes.@smartsoft001/crud-shell-dtosdefines what the change feed emits.@smartsoft001/domain-coredeclares the two repository contracts and the two errors this service raises.@smartsoft001/modelsprovidescastModelandgetInvalidFields, the trimming and the validation.@smartsoft001/nestjsprovides the permission service and the configuration it reads.@smartsoft001/crud-shell-nestjswires this service to HTTP and websockets.