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

@nicolaselge/ng-store

v0.0.5

Published

Angular Signals-based state management library with CRUD operations and DTO mapping

Readme

🧠 Angular Signal CRUD

Signal Store infrastructure for Angular 16+

A headless, opinionated CRUD infrastructure built on Angular Signals. Designed for long-lived applications, clean architectures, and real-world backend constraints.


✨ Features

  • Angular Signals-based stores - Reactive state management with Angular Signals
  • Class-based stores - No function-based defineStore, uses familiar class syntax
  • Full CRUD abstraction - Complete CRUD operations (single & bulk) out of the box
  • Optimistic updates - UI updates immediately
  • Soft delete, restore, hard delete - Complete deletion workflow support
  • Policy / permission layer - Frontend-side permission checking before operations
  • Event bus - Decouple stores and enable cross-store communication
  • Type-safe HTTP client - Full TypeScript support with comprehensive options
  • Automatic HTTP request logging - Dev mode only, configurable
  • DTO Mapping - Automatic transformation between backend DTOs and frontend entities
  • Headless & UI-agnostic - Works with any UI framework or library
  • Designed for Angular 16+ - Uses stable Angular Signals and functional DI

📦 Installation

npm install @nicolaselge/ng-store

🧩 Requirements

Peer dependencies

  • Angular >= 16.0.0
  • RxJS >= 7.5.0
  • TypeScript >= 5.0

This library relies on Angular Signals and functional dependency injection, which are officially stable starting from Angular 16.


🚀 Quick Start

1️⃣ Define your entity

export interface User {
  id: number;
  name: string;
  email: string;
  createdAt: string;
  updatedAt: string;
  deletedAt?: string | null;
}

2️⃣ Create a Store

import { BaseCrudStore } from '@nicolaselge/ng-store';

@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore<User> {
  protected override storeName = 'users';
  protected override endpoint = '/api/users';
}

3️⃣ Use it in a component

@Component({...})
export class UserListComponent {

  readonly users = this.userStore.entities;
  readonly loading = this.userStore.loading;

  private userStore: UserStore = inject(UserStore);

  constructor() {
    this.userStore.getAll();
  }

  delete(user: User) {
    this.userStore.deleteOne(user.id);
  }
}

📚 Complete API Reference

BaseCrudStore<T, TDTO>

The main store class that provides all CRUD operations.

Type Parameters:

  • T - Entity type (must have an id property)
  • TDTO - DTO type (defaults to T if no mapper is used)

Properties:

entities: Signal<T[]>

Reactive Signal containing all entities in the store.

Type: Signal<T[]>

Usage:

readonly users = this.userStore.entities;

// In template
<div *ngFor="let user of users()">{{ user.name }}</div>

loading: Signal<boolean>

Reactive Signal indicating if an operation is in progress.

Type: Signal<boolean>

Usage:

readonly loading = this.userStore.loading;

// In template
<div *ngIf="loading()">Loading...</div>

selected: Signal<T | null>

Reactive Signal containing the currently selected entity.

Type: Signal<T | null>

Usage:

readonly selectedUser = this.userStore.selected;

// In template
<div *ngIf="selectedUser()">
  Details: {{ selectedUser()!.name }}
</div>

Read Operations

getOne(id: T['id']): Promise<T>

Retrieves a single entity by its ID.

Parameters:

  • id - ID of the entity to retrieve

Returns: Promise resolved with the retrieved entity

Features:

  • Checks permissions via PolicyEngine
  • Updates selected with the retrieved entity
  • Emits event {storeName}:get:one
  • Automatic DTO mapper support

Endpoint: GET {endpoint}/:id

Example:

const user = await this.userStore.getOne(123);
console.log(user.name);

// Entity is automatically set in selected
const selected = this.userStore.selected(); // Signal containing the user

getAll(): Promise<T[]>

Retrieves all entities.

Returns: Promise resolved with the array of entities

Features:

  • Checks permissions via PolicyEngine
  • Completely replaces the items collection
  • Emits event {storeName}:get:all
  • Automatic DTO mapper support

Endpoint: GET {endpoint}

Example:

const users = await this.userStore.getAll();

// Collection is automatically updated
const allUsers = this.userStore.entities(); // Signal containing all users

getMany(ids: T['id'][]): Promise<T[]>

Retrieves multiple entities by their IDs.

Parameters:

  • ids - Array of IDs of entities to retrieve

Returns: Promise resolved with the array of retrieved entities

Features:

  • Checks permissions via PolicyEngine
  • Updates the items collection with retrieved entities (upsert)
  • Emits event {storeName}:get:many
  • Automatic DTO mapper support

Endpoint: POST {endpoint}/many Body: Array of entity IDs

Example:

const users = await this.userStore.getMany([1, 2, 3]);

// Entities are automatically added/updated in the collection

Create Operations

createOne(entity: T): Promise<T>

Creates a new entity.

Parameters:

  • entity - Entity to create

Returns: Promise resolved with the created entity (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Adds entity immediately to store
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:create:one
  • Automatic DTO mapper support

Endpoint: POST {endpoint} Body: Entity DTO (or entity if no mapper)

Example:

const newUser: User = {
  id: 0,
  name: 'John Doe',
  email: '[email protected]',
  createdAt: new Date().toISOString(),
  updatedAt: new Date().toISOString(),
  deletedAt: null
};

try {
  const created = await this.userStore.createOne(newUser);
  console.log('User created:', created);
  // User is already visible in this.userStore.entities() (optimistic)
} catch (error) {
  console.error('Error:', error);
  // State has been automatically restored (rollback)
}

createMany(entities: T[]): Promise<T[]>

Creates multiple new entities.

Parameters:

  • entities - Array of entities to create

Returns: Promise resolved with the created entities (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Adds entities immediately to store
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:create:many
  • Automatic DTO mapper support

Endpoint: POST {endpoint}/bulk Body: Array of entity DTOs (or entities if no mapper)

Example:

const newUsers: User[] = [
  { id: 0, name: 'John', email: '[email protected]', ... },
  { id: 0, name: 'Jane', email: '[email protected]', ... }
];

const created = await this.userStore.createMany(newUsers);

Update Operations

updateOne(entity: T): Promise<T>

Updates an existing entity (full replacement).

Parameters:

  • entity - Complete entity with updated values

Returns: Promise resolved with the updated entity (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Updates entity immediately in store
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:update:one
  • Automatic DTO mapper support

Endpoint: PUT {endpoint}/:id Body: Complete entity DTO (or entity if no mapper)

Example:

const updatedUser: User = {
  ...existingUser,
  name: 'John Updated',
  email: '[email protected]'
};

const result = await this.userStore.updateOne(updatedUser);

updateMany(entities: T[]): Promise<T[]>

Updates multiple existing entities (full replacement).

Parameters:

  • entities - Array of complete entities with updated values

Returns: Promise resolved with the updated entities (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Updates entities immediately in store
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:update:many
  • Automatic DTO mapper support

Endpoint: PUT {endpoint}/bulk Body: Array of complete entity DTOs


patchOne(entity: T): Promise<T>

Partially updates an existing entity (PATCH operation).

Parameters:

  • entity - Entity with only the fields to update (other fields can be omitted)

Returns: Promise resolved with the patched entity (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Updates entity immediately in store
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:patch:one
  • Automatic DTO mapper support

Endpoint: PATCH {endpoint}/:id Body: Partial entity DTO (only fields to update)

Example:

// Update only the name
const partial: User = {
  id: 123,
  name: 'New Name'
  // email and other fields not included
};

const result = await this.userStore.patchOne(partial);

patchMany(entities: T[]): Promise<T[]>

Partially updates multiple existing entities (PATCH operation).

Parameters:

  • entities - Array of entities with only fields to update

Returns: Promise resolved with the patched entities (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Updates entities immediately in store
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:patch:many
  • Automatic DTO mapper support

Endpoint: PATCH {endpoint}/bulk Body: Array of partial entity DTOs


Delete Operations

deleteOne(id: T['id']): Promise<T>

Soft deletes an entity (marks as deleted but keeps in database).

Parameters:

  • id - ID of the entity to soft delete

Returns: Promise resolved with the deleted entity (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Marks entity as deleted immediately (sets deletedAt)
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:delete:one
  • Automatic DTO mapper support

Endpoint: PATCH {endpoint}/:id/delete

Example:

// Soft delete a user
await this.userStore.deleteOne(123);
// Entity is marked as deleted (deletedAt is set)
// It can be restored with restoreOne()

deleteMany(ids: T['id'][]): Promise<T[]>

Soft deletes multiple entities (marks as deleted but keeps in database).

Parameters:

  • ids - Array of IDs of entities to soft delete

Returns: Promise resolved with the deleted entities (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Marks entities as deleted immediately (sets deletedAt)
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:delete:many
  • Automatic DTO mapper support

Endpoint: PATCH {endpoint}/delete Body: Array of entity IDs


restoreOne(id: T['id']): Promise<T>

Restores a soft-deleted entity (removes the deleted flag).

Parameters:

  • id - ID of the entity to restore

Returns: Promise resolved with the restored entity (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Removes deleted flag immediately (sets deletedAt to null)
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:restore:one
  • Automatic DTO mapper support

Endpoint: PATCH {endpoint}/:id/restore

Example:

// Restore a previously deleted user
await this.userStore.restoreOne(123);
// Entity is now active (deletedAt is null)

restoreMany(ids: T['id'][]): Promise<T[]>

Restores multiple soft-deleted entities (removes the deleted flag).

Parameters:

  • ids - Array of IDs of entities to restore

Returns: Promise resolved with the restored entities (returned by server)

Features:

  • Checks permissions via PolicyEngine
  • Optimistic update: Removes deleted flag immediately (sets deletedAt to null)
  • Automatic rollback: Restores state on error
  • Emits event {storeName}:restore:many
  • Automatic DTO mapper support

Endpoint: PATCH {endpoint}/restore Body: Array of entity IDs


destroyOne(id: T['id']): Promise<void>

Permanently deletes an entity from the database (hard delete).

Parameters:

  • id - ID of the entity to permanently delete

Returns: Promise that resolves when deletion is complete

Features:

  • Checks permissions via PolicyEngine (requires 'destroy' permission)
  • Optimistic update: Removes entity immediately from store
  • Automatic rollback: Restores state on error (except network errors)
  • Emits event {storeName}:destroy:one

Warning: This operation is irreversible. The entity is permanently deleted.

Endpoint: DELETE {endpoint}/:id

Example:

// Permanently delete a user
await this.userStore.destroyOne(123);
// Entity is removed from store and deleted from database

destroyMany(ids: T['id'][]): Promise<void>

Permanently deletes multiple entities from the database (hard delete).

Parameters:

  • ids - Array of IDs of entities to permanently delete

Returns: Promise that resolves when deletion is complete

Features:

  • Checks permissions via PolicyEngine (requires 'destroy' permission)
  • Optimistic update: Removes entities immediately from store
  • Automatic rollback: Restores state on error (except network errors)
  • Emits event {storeName}:destroy:many

Warning: This operation is irreversible. The entities are permanently deleted.

Endpoint: DELETE {endpoint} Body: Array of entity IDs



🔐 Policy / Permission Layer

Overview

The PolicyEngine service provides a centralized way to check permissions before CRUD operations. By default, all operations are allowed.

Custom Implementation

Override the PolicyEngine to implement your own permission logic:

import { Injectable, inject } from '@angular/core';
import { PolicyEngine, CrudAction } from '@nicolaselge/ng-store';
import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class CustomPolicyEngine extends PolicyEngine {
  private auth = inject(AuthService);

  override can(action: CrudAction, entity?: any): boolean {
    const user = this.auth.currentUser();
    if (!user) return false;

    switch (action) {
      case 'destroy':
        // Only admins can permanently delete
        return user.role === 'admin';
      
      case 'update':
        // Users can update their own entities, admins can update any
        return user.id === entity?.ownerId || user.role === 'admin';
      
      case 'create':
        // Only authenticated users can create
        return !!user;
      
      default:
        return true;
    }
  }
}

Provide the Custom Engine

// In app.config.ts
import { provideHttpClient } from '@angular/common/http';
import { PolicyEngine } from '@nicolaselge/ng-store';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    { provide: PolicyEngine, useClass: CustomPolicyEngine }
  ]
};

How it works

  • Before each CRUD operation, BaseCrudStore calls policy.can(action, entity)
  • If false, an error is thrown: Permission denied: cannot {action} entity in store '{storeName}'
  • The operation is blocked before any optimistic update or HTTP request

📡 Event Bus

Overview

The EventBus service enables decoupled communication between stores and other parts of your application.

How it works

Stores automatically emit events after successful CRUD operations:

  • Format: {storeName}:{action}:{type}
  • Examples: users:create:one, users:update:many, users:delete:one

Listening to Events

import { EventBus } from '@nicolaselge/ng-store';
import { inject, OnInit, OnDestroy } from '@angular/core';

@Component({...})
export class UserNotificationsComponent implements OnInit, OnDestroy {
  private eventBus = inject(EventBus);
  private notificationService = inject(NotificationService);

  ngOnInit() {
    // Listen to user creation events
    this.eventBus.on('users:create:one', (user) => {
      this.notificationService.show(`User ${user.name} created`);
    });

    // Listen to bulk updates
    this.eventBus.on('users:update:many', (users) => {
      this.notificationService.show(`${users.length} users updated`);
    });

    // Listen to all user events
    this.eventBus.on('users:delete:one', (user) => {
      this.analytics.track('user_deleted', { userId: user.id });
    });
  }
}

Available Events

For a store named users, the following events are emitted:

  • users:get:one - After getOne() succeeds
  • users:get:all - After getAll() succeeds
  • users:get:many - After getMany() succeeds
  • users:create:one - After createOne() succeeds
  • users:create:many - After createMany() succeeds
  • users:update:one - After updateOne() succeeds
  • users:update:many - After updateMany() succeeds
  • users:patch:one - After patchOne() succeeds
  • users:patch:many - After patchMany() succeeds
  • users:delete:one - After deleteOne() succeeds
  • users:delete:many - After deleteMany() succeeds
  • users:restore:one - After restoreOne() succeeds
  • users:restore:many - After restoreMany() succeeds
  • users:destroy:one - After destroyOne() succeeds
  • users:destroy:many - After destroyMany() succeeds

Use Cases

  • Analytics: Track user actions
  • Notifications: Show success/error messages
  • Cross-store updates: Update related stores when data changes
  • Side effects: Trigger additional operations
  • UI updates: Refresh related components

🔁 DTO Mapping (snake_case ↔ camelCase)

Why DTO Mapping?

This library works internally with camelCase entities, following TypeScript and Angular conventions.

If your backend API returns snake_case JSON (common with SQL-based or legacy backends), you must provide a DTO ↔ Entity mapper.

Benefits:

  • Keep frontend code idiomatic and clean
  • Decouple backend representation from frontend domain
  • Avoid leaking API formats into components and stores
  • Change backend format without touching components

Entity Mapper Interface

export interface EntityMapper<DTO, Entity> {
  fromDto(dto: DTO): Entity;
  toDto(entity: Entity): DTO;
}

Complete Example

1. Define DTO (Backend Format)

export interface UserDto {
  id: number;
  name: string;
  email: string;
  created_at: string;      // snake_case
  updated_at: string;      // snake_case
  deleted_at: string | null;
}

2. Define Entity (Frontend Format)

export interface User {
  id: number;
  name: string;
  email: string;
  createdAt: string;      // camelCase
  updatedAt: string;      // camelCase
  deletedAt: string | null;
}

3. Create the Mapper

import { Injectable } from '@angular/core';
import { EntityMapper } from '@nicolaselge/ng-store';

@Injectable({ providedIn: 'root' })
export class UserMapper implements EntityMapper<UserDto, User> {
  fromDto(dto: UserDto): User {
    return {
      id: dto.id,
      name: dto.name,
      email: dto.email,
      createdAt: dto.created_at,      // snake_case → camelCase
      updatedAt: dto.updated_at,      // snake_case → camelCase
      deletedAt: dto.deleted_at
    };
  }

  toDto(entity: User): UserDto {
    return {
      id: entity.id,
      name: entity.name,
      email: entity.email,
      created_at: entity.createdAt,    // camelCase → snake_case
      updated_at: entity.updatedAt,    // camelCase → snake_case
      deleted_at: entity.deletedAt
    };
  }
}

4. Create the Store with Mapper

import { Injectable, inject } from '@angular/core';
import { BaseCrudStore } from '@nicolaselge/ng-store';

@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore<User, UserDto> {
  protected override storeName = 'users';
  protected override endpoint = '/api/users';
  protected mapper = inject(UserMapper);
}

5. Use in Component

@Component({...})
export class UserComponent {
  private userStore = inject(UserStore);

  // The store returns User entities (camelCase), not UserDto
  readonly users = this.userStore.entities;

  async createUser() {
    // You work with User entities (camelCase)
    const newUser: User = {
      id: 0, // Will be set by the server
      name: 'John Doe',
      email: '[email protected]',
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      deletedAt: null
    };

    // The mapper automatically converts User → UserDto before sending
    await this.userStore.createOne(newUser);
  }
}

How it works

The transformation happens automatically in BaseCrudStore:

  1. On requests (POST, PUT, PATCH):

    • The entity is converted to DTO using mapper.toDto() before sending to the backend
    • Location: BaseCrudStore.toDto()mapper.toDto(entity)
  2. On responses (GET, POST, PUT, PATCH):

    • The DTO from the backend is converted to entity using mapper.fromDto()
    • This happens in the onSuccess callback and in the returned Promise
    • Location: BaseCrudStore.fromDto()mapper.fromDto(dto)
  3. In your components:

    • You always work with clean camelCase entities
    • The DTO transformation is completely transparent

🌐 HTTP Effects

Overview

HttpEffects is a fully typed HTTP client wrapper that extends Angular's HttpClient with additional features:

  • Type-safe requests with full TypeScript support
  • Automatic request/response logging (dev mode only)
  • Loading and error state management via Signals
  • Path parameter resolution (e.g., :id → actual value)
  • Query parameter handling
  • Callback support (onSuccess, onError)

Basic Usage

import { HttpEffects } from '@nicolaselge/ng-store';
import { inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpEffects);

  // Simple GET request
  getUser(id: number) {
    return this.http.get<User>('/api/users/:id', {
      onSuccess: (user) => console.log('User loaded:', user),
      onError: (error) => console.error('Failed to load user:', error),
    }).run({ path: { id } });
  }

  // POST with body and query parameters
  searchUsers(query: string) {
    return this.http.post<User[], SearchParams>('/api/users/search', {
    }).run({
      body: { query },
      query: { limit: 10, offset: 0 }
    });
  }

  // PUT with path parameters
  updateUser(user: User) {
    return this.http.put<User, User>('/api/users/:id', {
      onSuccess: (updated) => console.log('User updated:', updated),
    }).run({
      path: { id: user.id },
      body: user
    });
  }
}

HTTP Methods

All standard HTTP methods are supported:

  • get<TResult>(url, opts?) - GET request
  • post<TResult, TBody>(url, opts?) - POST request
  • put<TResult, TBody>(url, opts?) - PUT request
  • patch<TResult, TBody>(url, opts?) - PATCH request
  • delete<TResult, TBody>(url, opts?) - DELETE request

Request Options

interface HttpEffectsOptions<TResult, TBody> {
  // Custom options
  onSuccess?: (result: TResult, params?: HttpEffectsParams<TBody>) => void;
  onError?: (error: any) => void;

  // Standard Angular HTTP options
  headers?: HttpHeaders | Record<string, string | string[]>;
  observe?: 'body' | 'events' | 'response';
  responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
  reportProgress?: boolean;
  withCredentials?: boolean;
  timeout?: number;
  // ... and more (all Angular HttpClient options are supported)
}

Request Parameters

interface HttpEffectsParams<TBody> {
  path?: Record<string, string | number>;  // URL path parameters (e.g., :id)
  body?: TBody;                              // Request body
  query?: Record<string, any>;               // Query parameters
}

Return Value

All HTTP methods return an object with:

interface HttpEffectsResult<TResult> {
  run: (params?: HttpEffectsParams<any>) => Promise<TResult>;
  loading: Signal<boolean>;
  error: Signal<any>;
}

Example:

const request = this.http.get<User>('/api/users/:id');

// Access loading state
const isLoading = request.loading(); // Signal<boolean>

// Access error state
const error = request.error(); // Signal<any>

// Execute the request
const user = await request.run({ path: { id: 123 } });

📊 HTTP Logging

Overview

HTTP requests are automatically logged in development mode using isDevMode(). Logs are automatically disabled in production builds.

Log Format

Request:

[HTTP] GET /api/users/:id { path: { id: 123 } }

Success:

[HTTP] GET /api/users/123 → SUCCESS (45ms) { statusCode: 200, result: {...} }

Error:

[HTTP] POST /api/users → ERROR [400] (12ms) { error: {...} }

Customizing Logging Behavior

You can override the default behavior by providing a custom configuration:

import { STORE_LOGGER_CONFIG } from '@nicolaselge/ng-store';

// In your app.config.ts or module providers
providers: [
  {
    provide: STORE_LOGGER_CONFIG,
    useValue: {
      enableHttpLogs: true,   // Force enable (even in production)
      enableStoreLogs: false  // Disable store logs
    }
  }
]

Configuration Options

interface StoreLoggerConfig {
  /**
   * Force enable/disable HTTP logs.
   * If undefined, uses isDevMode() by default.
   */
  enableHttpLogs?: boolean;

  /**
   * Force enable/disable store logs.
   * If undefined, uses isDevMode() by default.
   */
  enableStoreLogs?: boolean;
}


🌐 Expected Backend REST Contract

Required Entity Fields (Logical Model)

Your entities should have these fields (names can vary, but concepts should exist):

{
  "id": "uuid | number",
  "createdAt": "ISO string",
  "updatedAt": "ISO string",
  "deletedAt": null
}

Endpoint Structure

The library expects the following REST endpoints:

Read:

  • GET {endpoint} - Get all entities
  • GET {endpoint}/:id - Get one entity
  • POST {endpoint}/many - Get many entities (body: array of IDs)

Create:

  • POST {endpoint} - Create one entity
  • POST {endpoint}/bulk - Create many entities

Update:

  • PUT {endpoint}/:id - Update one entity (full replacement)
  • PUT {endpoint}/bulk - Update many entities
  • PATCH {endpoint}/:id - Partially update one entity
  • PATCH {endpoint}/bulk - Partially update many entities

Delete:

  • PATCH {endpoint}/:id/delete - Soft delete one entity
  • PATCH {endpoint}/delete - Soft delete many entities (body: array of IDs)
  • PATCH {endpoint}/:id/restore - Restore one entity
  • PATCH {endpoint}/restore - Restore many entities (body: array of IDs)
  • DELETE {endpoint}/:id - Hard delete one entity
  • DELETE {endpoint} - Hard delete many entities (body: array of IDs)

📦 Philosophy

This library is designed for:

  • Large Angular applications - Scalable architecture for complex projects
  • Long-term maintainability - Clean code, clear patterns, comprehensive documentation
  • Clean, layered architectures - Separation of concerns, dependency injection
  • Real-world backend constraints - Handles snake_case, soft deletes, etc.

💡 Advanced Patterns

Custom Store with Additional Methods

You can extend BaseCrudStore with custom methods:

@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore<User> {
  protected override storeName = 'users';
  protected override endpoint = '/api/users';

  // Custom method
  async searchUsers(query: string) {
    return this.http.post<User[]>(
      `${this.endpoint}/search`,
    ).run({ body: { query } });
  }

  // Custom computed property
  get activeUsers() {
    return computed(() => 
      this.entities().filter(u => !u.deletedAt)
    );
  }
}

Listening to Store Events

@Component({...})
export class UserEffectsComponent implements OnInit {
  private eventBus = inject(EventBus);
  private analytics = inject(AnalyticsService);

  ngOnInit() {
    // Track user creation
    this.eventBus.on('users:create:one', (user) => {
      this.analytics.track('user_created', {
        userId: user.id,
        userName: user.name
      });
    });

    // Notify on bulk updates
    this.eventBus.on('users:update:many', (users) => {
      this.notificationService.show(
        `${users.length} users updated successfully`
      );
    });
  }
}

Custom Permission Logic

@Injectable({ providedIn: 'root' })
export class CustomPolicyEngine extends PolicyEngine {
  private auth = inject(AuthService);
  private userRoles = inject(UserRolesService);

  override can(action: CrudAction, entity?: any): boolean {
    const user = this.auth.currentUser();
    if (!user) return false;

    // Admin can do everything
    if (user.role === 'admin') return true;

    // Check specific permissions
    switch (action) {
      case 'destroy':
        return false; // Only admins (checked above)
      
      case 'update':
      case 'delete':
        // Users can modify their own entities
        return entity?.ownerId === user.id;
      
      case 'create':
        return this.userRoles.hasPermission(user, 'create:users');
      
      default:
        return true;
    }
  }
}

🎯 Best Practices

1. Store Naming

Use descriptive, plural names for stores:

// ✅ Good
protected override storeName = 'users';
protected override storeName = 'products';
protected override storeName = 'orders';

// ❌ Bad
protected override storeName = 'user';
protected override storeName = 'data';

2. Entity Structure

Always include required fields for proper CRUD operations:

export interface User {
  id: number;              // Required: unique identifier
  createdAt: string;       // Required: creation timestamp
  updatedAt: string;       // Required: update timestamp
  deletedAt?: string | null; // Optional: for soft delete
  // ... your custom fields
}

3. Error Handling

Always handle errors in your components:

async createUser(user: User) {
  try {
    await this.userStore.createOne(user);
    this.notificationService.showSuccess('User created');
  } catch (error: any) {
    if (error.message?.includes('Permission denied')) {
      this.notificationService.showError('You do not have permission');
    } else {
      this.notificationService.showError('Failed to create user');
    }
  }
}

4. Using Signals in Templates

Always call Signals as functions in templates:

// ✅ Good
<div *ngFor="let user of users()">{{ user.name }}</div>
<div *ngIf="loading()">Loading...</div>

// ❌ Bad
<div *ngFor="let user of users">{{ user.name }}</div>

5. DTO Mapping

Use mappers when your backend uses different naming conventions:

// ✅ Use mapper for snake_case backend
export class UserStore extends BaseCrudStore<User, UserDto> {
  protected mapper = inject(UserMapper);
}

// ✅ Skip mapper if backend uses camelCase
export class UserStore extends BaseCrudStore<User> {
  // No mapper needed
}

🐛 Troubleshooting

Events are not being received

Problem: Event listeners are not triggered.

Solution:

  • Ensure the event name matches exactly: {storeName}:{action}:{type}
  • Check that the store's storeName is correctly set
  • Verify the listener is registered before the event is emitted

Permissions are always denied

Problem: All operations throw "Permission denied" errors.

Solution:

  • Check your PolicyEngine implementation
  • Ensure can() method returns true for allowed operations
  • Verify the custom PolicyEngine is properly provided in your app config

DTO mapping not working

Problem: Entities are not transformed to/from DTOs.

Solution:

  • Verify the mapper is injected: protected mapper = inject(UserMapper);
  • Check that the store extends BaseCrudStore<User, UserDto> (with DTO type)
  • Ensure mapper methods (fromDto, toDto) are correctly implemented

Optimistic updates not visible

Problem: UI doesn't update immediately after mutations.

Solution:

  • Use Signals in templates: users() not users
  • Check that you're using the entities getter: this.userStore.entities
  • Verify the component is using change detection (Signals trigger it automatically)

🗺️ Roadmap

  • v1.0: Stable CRUD ✅
  • v1.1: IndexedDB adapter
  • v1.2: Devtools & inspection tools
  • v2.0: Optional GraphQL adapter

📝 License

[Your License Here]