mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
- Add new reward-order-confirmation feature library with components and store - Implement checkout completion orchestrator service for order finalization - Migrate checkout/oms/crm models to Zod schemas for better type safety - Add order creation facade and display order schemas - Update shopping cart facade with order completion flow - Add comprehensive tests for shopping cart facade - Update routing to include order confirmation page
1564 lines
39 KiB
Markdown
1564 lines
39 KiB
Markdown
# @isa/common/data-access
|
|
|
|
A foundational data access library providing core utilities, error handling, RxJS operators, response models, and advanced batching infrastructure for Angular applications.
|
|
|
|
## Overview
|
|
|
|
The Common Data Access library is the backbone of data layer functionality across the ISA application. It provides standardized error handling with specialized error classes, custom RxJS operators for request cancellation and keyboard-based flow control, response type definitions for API communication, and a sophisticated batching resource system for optimizing multiple concurrent API requests. This library enforces consistency across all domain-specific data access layers and reduces code duplication by centralizing common patterns.
|
|
|
|
## Table of Contents
|
|
|
|
- [Features](#features)
|
|
- [Quick Start](#quick-start)
|
|
- [Core Concepts](#core-concepts)
|
|
- [API Reference](#api-reference)
|
|
- [Usage Examples](#usage-examples)
|
|
- [Error Handling](#error-handling)
|
|
- [RxJS Operators](#rxjs-operators)
|
|
- [Batching Resource System](#batching-resource-system)
|
|
- [Testing](#testing)
|
|
- [Architecture Notes](#architecture-notes)
|
|
|
|
## Features
|
|
|
|
- **Structured error hierarchy** - Type-safe error classes with error codes and optional data
|
|
- **Response type definitions** - Standardized API response interfaces with error handling
|
|
- **Custom RxJS operators** - Request cancellation, keyboard-based abort, error transformation
|
|
- **Batching resource infrastructure** - Angular resource-based request batching with smart caching
|
|
- **Zod integration helpers** - User-friendly German error messages for validation failures
|
|
- **AbortSignal utilities** - Keyboard-triggered abort controllers for user-cancellable operations
|
|
- **Pagination support** - List response types with skip/take/hits metadata
|
|
- **Batch response handling** - Complex batch operation results with success/failure tracking
|
|
- **Type-safe async results** - Discriminated unions for async operation states
|
|
- **TypeScript strict mode** - Full type safety with minimal type assertions
|
|
|
|
## Quick Start
|
|
|
|
### 1. Using Error Classes
|
|
|
|
```typescript
|
|
import { ResponseArgsError, PropertyIsEmptyError } from '@isa/common/data-access';
|
|
|
|
// Check API response for errors
|
|
try {
|
|
const response = await api.fetchData();
|
|
if (response.error) {
|
|
throw new ResponseArgsError(response);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ResponseArgsError) {
|
|
console.error('API error:', error.message);
|
|
console.error('Invalid properties:', error.responseArgs.invalidProperties);
|
|
}
|
|
}
|
|
|
|
// Validate required properties
|
|
if (!userId || userId.trim() === '') {
|
|
throw new PropertyIsEmptyError('userId');
|
|
}
|
|
```
|
|
|
|
### 2. Using RxJS Operators
|
|
|
|
```typescript
|
|
import { takeUntilAborted, catchResponseArgsErrorPipe } from '@isa/common/data-access';
|
|
|
|
// Cancel request with AbortSignal
|
|
fetchData(signal: AbortSignal) {
|
|
return this.api.getData().pipe(
|
|
takeUntilAborted(signal),
|
|
catchResponseArgsErrorPipe()
|
|
);
|
|
}
|
|
|
|
// Cancel on Escape key
|
|
import { takeUntilKeydownEscape } from '@isa/common/data-access';
|
|
|
|
searchProducts(query: string) {
|
|
return this.api.search(query).pipe(
|
|
takeUntilKeydownEscape(),
|
|
catchResponseArgsErrorPipe()
|
|
);
|
|
}
|
|
```
|
|
|
|
### 3. Using Batching Resource
|
|
|
|
```typescript
|
|
import { BatchingResource } from '@isa/common/data-access';
|
|
|
|
// Create a custom batching resource
|
|
export class StockInfoResource extends BatchingResource<
|
|
number, // Item ID (params)
|
|
FetchStockParams, // API request params
|
|
StockInfo // Result type
|
|
> {
|
|
#stockService = inject(StockService);
|
|
|
|
constructor() {
|
|
super(250); // Batch window: 250ms
|
|
}
|
|
|
|
protected async fetchFn(params: FetchStockParams, signal: AbortSignal) {
|
|
return this.#stockService.fetchStockInfos(params, signal);
|
|
}
|
|
|
|
protected buildParams(itemIds: number[]): FetchStockParams {
|
|
return { itemIds };
|
|
}
|
|
|
|
protected getKeyFromResult(stock: StockInfo): number | undefined {
|
|
return stock.itemId;
|
|
}
|
|
|
|
protected getCacheKey(itemId: number): string {
|
|
return String(itemId);
|
|
}
|
|
|
|
protected extractKeysFromParams(params: FetchStockParams): number[] {
|
|
return params.itemIds;
|
|
}
|
|
}
|
|
|
|
// Use in components
|
|
const stockResource = inject(StockInfoResource);
|
|
const stockInfo = stockResource.resource(itemId);
|
|
const inStock = computed(() => stockInfo.value()?.inStock ?? 0);
|
|
```
|
|
|
|
### 4. Handling Zod Validation Errors
|
|
|
|
```typescript
|
|
import { extractZodErrorMessage } from '@isa/common/data-access';
|
|
import { z } from 'zod';
|
|
|
|
const userSchema = z.object({
|
|
name: z.string().min(3),
|
|
email: z.string().email(),
|
|
age: z.number().min(18)
|
|
});
|
|
|
|
try {
|
|
userSchema.parse(invalidData);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
const message = extractZodErrorMessage(error);
|
|
// Returns German user-friendly message:
|
|
// "Es sind 3 Validierungsfehler aufgetreten:
|
|
// • name: Text muss mindestens 3 Zeichen lang sein
|
|
// • email: Ungültige E-Mail-Adresse
|
|
// • age: Wert muss mindestens 18 sein"
|
|
this.showError(message);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Core Concepts
|
|
|
|
### Error Hierarchy
|
|
|
|
The library provides a structured error hierarchy with type-safe error codes:
|
|
|
|
```
|
|
Error (JavaScript built-in)
|
|
↓
|
|
DataAccessError<TCode, TData>
|
|
↓
|
|
├─→ ResponseArgsError (API errors)
|
|
├─→ PropertyIsEmptyError (validation)
|
|
└─→ PropertyNullOrUndefinedError (validation)
|
|
```
|
|
|
|
**Key Features:**
|
|
- Type-safe error codes using string literals
|
|
- Optional typed data attached to errors
|
|
- Consistent error message formatting
|
|
- Proper prototype chain for `instanceof` checks
|
|
|
|
### Response Type Definitions
|
|
|
|
All API responses follow standardized interfaces:
|
|
|
|
#### ResponseArgs<T>
|
|
Standard single-item response:
|
|
```typescript
|
|
interface ResponseArgs<T> {
|
|
error: boolean; // Error flag
|
|
invalidProperties?: Record<string, string>; // Validation errors
|
|
message?: string; // Error/success message
|
|
result: T; // Actual data
|
|
}
|
|
```
|
|
|
|
#### ListResponseArgs<T>
|
|
Paginated list response:
|
|
```typescript
|
|
interface ListResponseArgs<T> extends ResponseArgs<T[]> {
|
|
skip: number; // Pagination offset
|
|
take: number; // Page size
|
|
hits: number; // Total count
|
|
}
|
|
```
|
|
|
|
#### BatchResponseArgs<T>
|
|
Batch operation results:
|
|
```typescript
|
|
interface BatchResponseArgs<T> {
|
|
alreadyProcessed?: Array<ReturnValue<T>>;
|
|
ambiguous?: Array<T>;
|
|
completed: boolean;
|
|
duplicates?: Array<{ key: T; value: number }>;
|
|
error: boolean;
|
|
failed?: Array<ReturnValue<T>>;
|
|
invalidProperties?: Record<string, string>;
|
|
message?: string;
|
|
requestId?: number;
|
|
successful?: Array<{ key: T; value: T }>;
|
|
total: number;
|
|
unknown?: Array<ReturnValue<T>>;
|
|
}
|
|
```
|
|
|
|
### RxJS Operator Pipeline
|
|
|
|
Custom operators integrate seamlessly with standard RxJS operators:
|
|
|
|
```typescript
|
|
this.api.fetchData().pipe(
|
|
takeUntilAborted(abortSignal), // Cancellation
|
|
map(response => response.result), // Transformation
|
|
catchResponseArgsErrorPipe(), // Error handling
|
|
tap(data => this.#logger.info(data)) // Side effects
|
|
);
|
|
```
|
|
|
|
### Batching Resource Architecture
|
|
|
|
The `BatchingResource` abstract class provides:
|
|
|
|
1. **Request Batching** - Collects params from multiple components
|
|
2. **Batch Window** - Waits for configurable time before executing
|
|
3. **Smart Caching** - Caches results including empty responses
|
|
4. **Reference Counting** - Tracks component usage for cleanup
|
|
5. **Per-Item Status** - Independent loading/error states
|
|
6. **Automatic Cleanup** - Removes cached data when no longer needed
|
|
|
|
**Lifecycle:**
|
|
```
|
|
Component A requests item 123
|
|
↓
|
|
BatchingResource adds to pending queue
|
|
↓
|
|
Component B requests item 456 (within batch window)
|
|
↓
|
|
Batch window expires (e.g., 250ms)
|
|
↓
|
|
Single API call: fetchItems([123, 456])
|
|
↓
|
|
Results cached with individual status
|
|
↓
|
|
Component A destroys → ref count decreases
|
|
↓
|
|
Component B destroys → ref count = 0 → cache cleared
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### Error Classes
|
|
|
|
#### `DataAccessError<TCode, TData>`
|
|
|
|
Base error class for all data access errors with type-safe error codes.
|
|
|
|
**Type Parameters:**
|
|
- `TCode extends string` - String literal type for error code
|
|
- `TData = void` - Type for additional error data (defaults to void)
|
|
|
|
**Constructor:**
|
|
```typescript
|
|
constructor(
|
|
code: TCode,
|
|
message: string,
|
|
data: TData
|
|
)
|
|
```
|
|
|
|
**Properties:**
|
|
- `code: TCode` - Unique error code identifier
|
|
- `message: string` - Human-readable error description
|
|
- `data: TData` - Additional context-specific error data
|
|
- `name: string` - Error class name (automatically set)
|
|
|
|
**Example:**
|
|
```typescript
|
|
class CustomError extends DataAccessError<'CUSTOM_ERROR', { field: string }> {
|
|
constructor(field: string) {
|
|
super('CUSTOM_ERROR', `Validation failed for ${field}`, { field });
|
|
}
|
|
}
|
|
|
|
const error = new CustomError('email');
|
|
console.log(error.code); // 'CUSTOM_ERROR'
|
|
console.log(error.data.field); // 'email'
|
|
```
|
|
|
|
#### `ResponseArgsError<T>`
|
|
|
|
Error thrown when API response indicates an error condition.
|
|
|
|
**Constructor:**
|
|
```typescript
|
|
constructor(responseArgs: Omit<ResponseArgs<T>, 'result'>)
|
|
```
|
|
|
|
**Properties:**
|
|
- `responseArgs: Omit<ResponseArgs<T>, 'result'>` - Original response without result
|
|
- `code: 'RESPONSE_ARGS_ERROR'` - Fixed error code
|
|
- `message: string` - Constructed from response message or invalid properties
|
|
|
|
**Example:**
|
|
```typescript
|
|
const response = await api.fetchUser(userId);
|
|
|
|
if (response.error) {
|
|
throw new ResponseArgsError(response);
|
|
// Message: "User not found" (from response.message)
|
|
// Or: "email: Invalid format; age: Must be positive"
|
|
}
|
|
```
|
|
|
|
#### `PropertyIsEmptyError`
|
|
|
|
Error for empty string properties that should have values.
|
|
|
|
**Constructor:**
|
|
```typescript
|
|
constructor(propertyName: string)
|
|
```
|
|
|
|
**Properties:**
|
|
- `code: 'PROPERTY_IS_EMPTY'` - Fixed error code
|
|
- `message: string` - "Property \"{propertyName}\" is empty."
|
|
|
|
**Example:**
|
|
```typescript
|
|
function validateUsername(username: string) {
|
|
if (!username || username.trim() === '') {
|
|
throw new PropertyIsEmptyError('username');
|
|
}
|
|
}
|
|
```
|
|
|
|
#### `PropertyNullOrUndefinedError`
|
|
|
|
Error for null or undefined properties that are required.
|
|
|
|
**Constructor:**
|
|
```typescript
|
|
constructor(propertyName: string)
|
|
```
|
|
|
|
**Properties:**
|
|
- `code: 'PROPERTY_NULL_OR_UNDEFINED'` - Fixed error code
|
|
- `message: string` - "Property \"{propertyName}\" is null or undefined."
|
|
|
|
**Example:**
|
|
```typescript
|
|
function processUser(user: User | null) {
|
|
if (user === null || user === undefined) {
|
|
throw new PropertyNullOrUndefinedError('user');
|
|
}
|
|
// Process user...
|
|
}
|
|
```
|
|
|
|
### RxJS Operators
|
|
|
|
#### `takeUntilAborted<T>(signal: AbortSignal)`
|
|
|
|
Completes the observable when the provided AbortSignal is aborted.
|
|
|
|
**Parameters:**
|
|
- `signal: AbortSignal` - Signal that triggers completion when aborted
|
|
|
|
**Returns:** `OperatorFunction<T, T>`
|
|
|
|
**Example:**
|
|
```typescript
|
|
fetchData(signal: AbortSignal): Observable<Data> {
|
|
return this.httpClient.get<Data>(url).pipe(
|
|
takeUntilAborted(signal)
|
|
);
|
|
}
|
|
|
|
// Usage
|
|
const controller = new AbortController();
|
|
const data$ = this.service.fetchData(controller.signal);
|
|
|
|
// Cancel after 5 seconds
|
|
setTimeout(() => controller.abort(), 5000);
|
|
```
|
|
|
|
#### `takeUntilKeydown<T>(key: string)`
|
|
|
|
Completes the observable when a specific keyboard key is pressed.
|
|
|
|
**Parameters:**
|
|
- `key: string` - Keyboard key name (e.g., 'Escape', 'Enter', 'Backspace')
|
|
|
|
**Returns:** `OperatorFunction<T, T>`
|
|
|
|
**Example:**
|
|
```typescript
|
|
// Cancel search on any key press
|
|
search$ = this.searchControl.valueChanges.pipe(
|
|
debounceTime(300),
|
|
switchMap(query =>
|
|
this.api.search(query).pipe(
|
|
takeUntilKeydown('Escape') // User can cancel with Escape
|
|
)
|
|
)
|
|
);
|
|
```
|
|
|
|
#### `takeUntilKeydownEscape<T>()`
|
|
|
|
Convenience operator that completes on Escape key press.
|
|
|
|
**Returns:** `OperatorFunction<T, T>`
|
|
|
|
**Example:**
|
|
```typescript
|
|
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
|
import { takeUntilKeydownEscape } from '@isa/common/data-access';
|
|
|
|
loadData = rxMethod<number>(
|
|
pipe(
|
|
switchMap(id =>
|
|
this.api.fetchData(id).pipe(
|
|
takeUntilKeydownEscape(), // User-cancellable
|
|
tapResponse(
|
|
data => this.patchState({ data }),
|
|
error => this.#logger.error('Load failed', error)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
```
|
|
|
|
#### `catchResponseArgsErrorPipe<T>()`
|
|
|
|
Catches HTTP errors and transforms them into ResponseArgsError instances.
|
|
|
|
**Returns:** `OperatorFunction<T, T>`
|
|
|
|
**Behavior:**
|
|
- If error is already `ResponseArgsError`, re-throws as-is
|
|
- If error is `HttpErrorResponse` with `ResponseArgs` payload, creates `ResponseArgsError`
|
|
- Otherwise, re-throws original error
|
|
|
|
**Example:**
|
|
```typescript
|
|
saveUser(user: User): Observable<User> {
|
|
return this.httpClient.post<ResponseArgs<User>>(url, user).pipe(
|
|
catchResponseArgsErrorPipe(),
|
|
map(response => response.result)
|
|
);
|
|
}
|
|
|
|
// Catch and handle
|
|
this.service.saveUser(user).subscribe({
|
|
next: savedUser => console.log('Saved:', savedUser),
|
|
error: error => {
|
|
if (error instanceof ResponseArgsError) {
|
|
// Display API validation errors
|
|
this.showErrors(error.responseArgs.invalidProperties);
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
### Helper Functions
|
|
|
|
#### `extractZodErrorMessage(error: ZodError): string`
|
|
|
|
Extracts and formats user-friendly German error messages from Zod validation errors.
|
|
|
|
**Parameters:**
|
|
- `error: ZodError` - Zod validation error instance
|
|
|
|
**Returns:** Formatted German error message string
|
|
|
|
**Features:**
|
|
- German translations for all Zod error types
|
|
- Field path formatting with → separators
|
|
- Type translations (string → Text, number → Zahl)
|
|
- Detailed messages for size constraints
|
|
- Support for arrays, dates, emails, URLs, etc.
|
|
|
|
**Example:**
|
|
```typescript
|
|
import { extractZodErrorMessage } from '@isa/common/data-access';
|
|
import { z } from 'zod';
|
|
|
|
const schema = z.object({
|
|
name: z.string().min(3).max(50),
|
|
email: z.string().email(),
|
|
age: z.number().min(18),
|
|
items: z.array(z.string()).min(1)
|
|
});
|
|
|
|
try {
|
|
schema.parse({
|
|
name: 'Jo',
|
|
email: 'invalid',
|
|
age: 15,
|
|
items: []
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
console.log(extractZodErrorMessage(error));
|
|
// Output:
|
|
// Es sind 4 Validierungsfehler aufgetreten:
|
|
//
|
|
// • name: Text muss mindestens 3 Zeichen lang sein
|
|
// • email: Ungültige E-Mail-Adresse
|
|
// • age: Wert muss mindestens 18 sein
|
|
// • items: Liste muss mindestens 1 Elemente enthalten
|
|
}
|
|
}
|
|
```
|
|
|
|
#### `createEscAbortControllerHelper(): AbortController`
|
|
|
|
Creates an AbortController that aborts when the Escape key is pressed.
|
|
|
|
**Returns:** AbortController that self-aborts on Escape key
|
|
|
|
**Features:**
|
|
- Automatically attaches keyboard listener to document
|
|
- Cleans up listener when aborted
|
|
- Useful for user-cancellable long-running operations
|
|
|
|
**Example:**
|
|
```typescript
|
|
import { createEscAbortControllerHelper } from '@isa/common/data-access';
|
|
|
|
async function searchWithEscapeCancel(query: string) {
|
|
const escController = createEscAbortControllerHelper();
|
|
|
|
try {
|
|
const results = await fetch(`/api/search?q=${query}`, {
|
|
signal: escController.signal
|
|
});
|
|
return await results.json();
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
console.log('Search cancelled by user (Escape pressed)');
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Models and Types
|
|
|
|
#### `AsyncResult<T>`
|
|
|
|
Interface representing the state of an asynchronous operation.
|
|
|
|
```typescript
|
|
const AsyncResultStatus = {
|
|
Idle: 'Idle',
|
|
Pending: 'Pending',
|
|
Success: 'Success',
|
|
Error: 'Error',
|
|
} as const;
|
|
|
|
interface AsyncResult<T> {
|
|
status: AsyncResultStatus;
|
|
data?: T;
|
|
error?: unknown;
|
|
}
|
|
```
|
|
|
|
**Example:**
|
|
```typescript
|
|
import { AsyncResult, AsyncResultStatus } from '@isa/common/data-access';
|
|
|
|
class DataStore {
|
|
userData = signal<AsyncResult<User>>({
|
|
status: AsyncResultStatus.Idle
|
|
});
|
|
|
|
async loadUser(id: number) {
|
|
this.userData.set({ status: AsyncResultStatus.Pending });
|
|
|
|
try {
|
|
const user = await this.api.fetchUser(id);
|
|
this.userData.set({
|
|
status: AsyncResultStatus.Success,
|
|
data: user
|
|
});
|
|
} catch (error) {
|
|
this.userData.set({
|
|
status: AsyncResultStatus.Error,
|
|
error
|
|
});
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### `CallbackResult<T>`
|
|
|
|
Discriminated union for callback-based async results.
|
|
|
|
```typescript
|
|
type CallbackResult<T> =
|
|
| { data: T; error?: unknown }
|
|
| { data?: T; error: unknown };
|
|
|
|
type Callback<T> = (result: CallbackResult<T>) => void;
|
|
```
|
|
|
|
**Example:**
|
|
```typescript
|
|
import { CallbackResult, Callback } from '@isa/common/data-access';
|
|
|
|
function fetchData(callback: Callback<User>): void {
|
|
api.getUser()
|
|
.then(user => callback({ data: user }))
|
|
.catch(error => callback({ error }));
|
|
}
|
|
|
|
// Usage
|
|
fetchData((result) => {
|
|
if (result.error) {
|
|
console.error('Failed:', result.error);
|
|
} else {
|
|
console.log('Success:', result.data);
|
|
}
|
|
});
|
|
```
|
|
|
|
#### `ReturnValue<T>`
|
|
|
|
Represents the result of an operation with optional error information.
|
|
|
|
```typescript
|
|
interface ReturnValue<T> {
|
|
error: boolean;
|
|
invalidProperties?: Record<string, string>;
|
|
message?: string;
|
|
result: T;
|
|
}
|
|
```
|
|
|
|
**Used In:** `BatchResponseArgs` for tracking individual item results
|
|
|
|
### Batching Resource
|
|
|
|
#### `BatchingResource<TParams, TResourceParams, TResourceValue>`
|
|
|
|
Abstract base class for implementing request batching with Angular resources.
|
|
|
|
**Type Parameters:**
|
|
- `TParams` - Individual item parameter type (e.g., `number` for item ID)
|
|
- `TResourceParams` - Batch API request parameter type (e.g., `{ itemIds: number[] }`)
|
|
- `TResourceValue` - Result type for individual items (e.g., `StockInfo`)
|
|
|
|
**Constructor:**
|
|
```typescript
|
|
constructor(batchWindowMs = 100)
|
|
```
|
|
|
|
**Abstract Methods (Must Implement):**
|
|
|
|
```typescript
|
|
// Fetch data for multiple params
|
|
protected abstract fetchFn(
|
|
params: TResourceParams,
|
|
abortSignal: AbortSignal
|
|
): Promise<TResourceValue[]>;
|
|
|
|
// Build API params from item params
|
|
protected abstract buildParams(params: TParams[]): TResourceParams;
|
|
|
|
// Extract params from result for cache matching
|
|
protected abstract getKeyFromResult(result: TResourceValue): TParams | undefined;
|
|
|
|
// Generate cache key from params
|
|
protected abstract getCacheKey(params: TParams): string;
|
|
|
|
// Extract original params from API params (for status tracking)
|
|
protected abstract extractKeysFromParams(params: TResourceParams): TParams[];
|
|
```
|
|
|
|
**Public Methods:**
|
|
|
|
##### `resource(params: Signal<TParams> | TParams): BatchingResourceRef<TResourceValue>`
|
|
|
|
Creates a resource reference for specific params with automatic batching and cleanup.
|
|
|
|
**Parameters:**
|
|
- `params: Signal<TParams> | TParams` - Item params (can be signal or static value)
|
|
|
|
**Returns:** `BatchingResourceRef<TResourceValue>` with properties:
|
|
- `value: Signal<TResourceValue | undefined>` - Resource value
|
|
- `hasValue: Signal<boolean>` - Whether value has been loaded
|
|
- `isLoading: Signal<boolean>` - Loading state
|
|
- `error: Signal<unknown>` - Error state
|
|
- `status: Signal<ResourceStatus>` - Current status (idle/loading/resolved/error)
|
|
- `reload: () => void` - Reload this specific item
|
|
|
|
**Example:**
|
|
```typescript
|
|
// In component
|
|
const itemId = signal(123);
|
|
const stockInfo = stockResource.resource(itemId);
|
|
|
|
// Template
|
|
@if (stockInfo.isLoading()) {
|
|
<div>Loading stock info...</div>
|
|
} @else if (stockInfo.error()) {
|
|
<div>Error: {{ stockInfo.error() }}</div>
|
|
} @else if (stockInfo.hasValue()) {
|
|
<div>In Stock: {{ stockInfo.value()?.quantity }}</div>
|
|
}
|
|
```
|
|
|
|
##### `reload(): void`
|
|
|
|
Reloads all currently requested items by clearing cache.
|
|
|
|
##### `reloadKeys(params: TParams[]): void`
|
|
|
|
Reloads specific items by removing them from cache.
|
|
|
|
**Example:**
|
|
```typescript
|
|
// Reload all items
|
|
stockResource.reload();
|
|
|
|
// Reload specific items
|
|
stockResource.reloadKeys([123, 456]);
|
|
```
|
|
|
|
## Usage Examples
|
|
|
|
### Error Handling Patterns
|
|
|
|
#### API Response Validation
|
|
|
|
```typescript
|
|
import { ResponseArgsError } from '@isa/common/data-access';
|
|
import { logger } from '@isa/core/logging';
|
|
|
|
class UserService {
|
|
#logger = logger(() => ({ service: 'UserService' }));
|
|
|
|
async createUser(userData: CreateUserDto): Promise<User> {
|
|
const response = await this.api.createUser(userData);
|
|
|
|
if (response.error) {
|
|
this.#logger.error('User creation failed', {
|
|
message: response.message,
|
|
invalidProperties: response.invalidProperties
|
|
});
|
|
throw new ResponseArgsError(response);
|
|
}
|
|
|
|
return response.result;
|
|
}
|
|
}
|
|
|
|
// Component usage
|
|
try {
|
|
const user = await this.#userService.createUser(formData);
|
|
this.showSuccess('User created successfully');
|
|
} catch (error) {
|
|
if (error instanceof ResponseArgsError) {
|
|
// Display validation errors to user
|
|
const errors = error.responseArgs.invalidProperties;
|
|
Object.entries(errors ?? {}).forEach(([field, message]) => {
|
|
this.form.get(field)?.setErrors({ server: message });
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Property Validation
|
|
|
|
```typescript
|
|
import {
|
|
PropertyIsEmptyError,
|
|
PropertyNullOrUndefinedError
|
|
} from '@isa/common/data-access';
|
|
|
|
class OrderService {
|
|
validateOrder(order: Order | null): asserts order is Order {
|
|
if (order === null || order === undefined) {
|
|
throw new PropertyNullOrUndefinedError('order');
|
|
}
|
|
|
|
if (!order.customerId || order.customerId.trim() === '') {
|
|
throw new PropertyIsEmptyError('customerId');
|
|
}
|
|
|
|
if (order.items.length === 0) {
|
|
throw new PropertyIsEmptyError('items');
|
|
}
|
|
}
|
|
|
|
async processOrder(order: Order | null): Promise<void> {
|
|
this.validateOrder(order);
|
|
// TypeScript now knows order is non-null Order
|
|
await this.api.submitOrder(order);
|
|
}
|
|
}
|
|
```
|
|
|
|
### RxJS Operator Combinations
|
|
|
|
#### User-Cancellable Search
|
|
|
|
```typescript
|
|
import { Component, inject, signal } from '@angular/core';
|
|
import { takeUntilKeydownEscape, catchResponseArgsErrorPipe } from '@isa/common/data-access';
|
|
import { debounceTime, switchMap } from 'rxjs/operators';
|
|
|
|
@Component({
|
|
selector: 'app-product-search',
|
|
template: `
|
|
<input
|
|
type="search"
|
|
[(ngModel)]="searchQuery"
|
|
placeholder="Search products (Escape to cancel)..." />
|
|
|
|
@if (searching()) {
|
|
<div>Searching... (Press Escape to cancel)</div>
|
|
}
|
|
|
|
@for (product of products(); track product.id) {
|
|
<div>{{ product.name }}</div>
|
|
}
|
|
`
|
|
})
|
|
export class ProductSearchComponent {
|
|
#searchService = inject(ProductSearchService);
|
|
|
|
searchQuery = signal('');
|
|
searching = signal(false);
|
|
products = signal<Product[]>([]);
|
|
|
|
constructor() {
|
|
// React to search query changes
|
|
effect(() => {
|
|
const query = this.searchQuery();
|
|
if (query.length < 3) return;
|
|
|
|
this.searching.set(true);
|
|
|
|
this.#searchService.search(query).pipe(
|
|
debounceTime(300),
|
|
takeUntilKeydownEscape(), // User can press Escape to cancel
|
|
catchResponseArgsErrorPipe()
|
|
).subscribe({
|
|
next: results => {
|
|
this.products.set(results);
|
|
this.searching.set(false);
|
|
},
|
|
error: () => {
|
|
this.searching.set(false);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
#### AbortSignal Integration
|
|
|
|
```typescript
|
|
import { Component, inject, DestroyRef } from '@angular/core';
|
|
import { takeUntilAborted, catchResponseArgsErrorPipe } from '@isa/common/data-access';
|
|
|
|
@Component({
|
|
selector: 'app-data-loader',
|
|
template: '...'
|
|
})
|
|
export class DataLoaderComponent {
|
|
#destroyRef = inject(DestroyRef);
|
|
#abortController = new AbortController();
|
|
|
|
constructor() {
|
|
// Abort on component destroy
|
|
this.#destroyRef.onDestroy(() => {
|
|
this.#abortController.abort();
|
|
});
|
|
}
|
|
|
|
loadData() {
|
|
return this.api.fetchData().pipe(
|
|
takeUntilAborted(this.#abortController.signal),
|
|
catchResponseArgsErrorPipe(),
|
|
tap(data => this.processData(data))
|
|
);
|
|
}
|
|
|
|
cancel() {
|
|
this.#abortController.abort();
|
|
this.#abortController = new AbortController(); // Create new for next request
|
|
}
|
|
}
|
|
```
|
|
|
|
### Batching Resource Implementation
|
|
|
|
#### Stock Information Batching
|
|
|
|
```typescript
|
|
import { Injectable } from '@angular/core';
|
|
import { BatchingResource } from '@isa/common/data-access';
|
|
|
|
interface FetchStockParams {
|
|
itemIds: number[];
|
|
}
|
|
|
|
interface StockInfo {
|
|
itemId: number;
|
|
inStock: number;
|
|
reserved: number;
|
|
available: number;
|
|
}
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class StockInfoResource extends BatchingResource<
|
|
number,
|
|
FetchStockParams,
|
|
StockInfo
|
|
> {
|
|
#stockService = inject(StockService);
|
|
|
|
constructor() {
|
|
super(250); // 250ms batch window
|
|
}
|
|
|
|
protected async fetchFn(
|
|
params: FetchStockParams,
|
|
signal: AbortSignal
|
|
): Promise<StockInfo[]> {
|
|
return this.#stockService.fetchStockInfos(params, signal);
|
|
}
|
|
|
|
protected buildParams(itemIds: number[]): FetchStockParams {
|
|
return { itemIds };
|
|
}
|
|
|
|
protected getKeyFromResult(stock: StockInfo): number | undefined {
|
|
return stock.itemId;
|
|
}
|
|
|
|
protected getCacheKey(itemId: number): string {
|
|
return String(itemId);
|
|
}
|
|
|
|
protected extractKeysFromParams(params: FetchStockParams): number[] {
|
|
return params.itemIds;
|
|
}
|
|
}
|
|
|
|
// Usage in component
|
|
@Component({
|
|
selector: 'app-product-card',
|
|
template: `
|
|
<div class="product">
|
|
<h3>{{ product().name }}</h3>
|
|
|
|
@if (stockInfo.isLoading()) {
|
|
<div>Loading stock...</div>
|
|
} @else if (stockInfo.error()) {
|
|
<div class="error">Stock unavailable</div>
|
|
} @else {
|
|
<div class="stock">
|
|
Available: {{ stockInfo.value()?.available ?? 0 }}
|
|
</div>
|
|
}
|
|
</div>
|
|
`
|
|
})
|
|
export class ProductCardComponent {
|
|
#stockResource = inject(StockInfoResource);
|
|
|
|
product = input.required<Product>();
|
|
|
|
// Automatically batches with other ProductCardComponents
|
|
stockInfo = this.#stockResource.resource(
|
|
computed(() => this.product().itemId)
|
|
);
|
|
}
|
|
```
|
|
|
|
#### Complex Batching with Custom Cache Keys
|
|
|
|
```typescript
|
|
interface PriceParams {
|
|
itemId: number;
|
|
customerId: string;
|
|
quantity: number;
|
|
}
|
|
|
|
interface FetchPricesParams {
|
|
requests: Array<{
|
|
itemId: number;
|
|
customerId: string;
|
|
quantity: number;
|
|
}>;
|
|
}
|
|
|
|
interface PriceInfo {
|
|
itemId: number;
|
|
customerId: string;
|
|
quantity: number;
|
|
price: number;
|
|
discount: number;
|
|
}
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class CustomerPriceResource extends BatchingResource<
|
|
PriceParams,
|
|
FetchPricesParams,
|
|
PriceInfo
|
|
> {
|
|
#pricingService = inject(PricingService);
|
|
|
|
constructor() {
|
|
super(100);
|
|
}
|
|
|
|
protected async fetchFn(
|
|
params: FetchPricesParams,
|
|
signal: AbortSignal
|
|
): Promise<PriceInfo[]> {
|
|
return this.#pricingService.fetchPrices(params, signal);
|
|
}
|
|
|
|
protected buildParams(requests: PriceParams[]): FetchPricesParams {
|
|
return { requests };
|
|
}
|
|
|
|
protected getKeyFromResult(price: PriceInfo): PriceParams | undefined {
|
|
return {
|
|
itemId: price.itemId,
|
|
customerId: price.customerId,
|
|
quantity: price.quantity
|
|
};
|
|
}
|
|
|
|
protected getCacheKey(params: PriceParams): string {
|
|
// Composite key for complex caching
|
|
return `${params.itemId}-${params.customerId}-${params.quantity}`;
|
|
}
|
|
|
|
protected extractKeysFromParams(params: FetchPricesParams): PriceParams[] {
|
|
return params.requests;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Zod Integration with Error Messages
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
import { extractZodErrorMessage } from '@isa/common/data-access';
|
|
|
|
// Define schema
|
|
const orderSchema = z.object({
|
|
customerId: z.string().min(1, 'Customer required'),
|
|
items: z.array(
|
|
z.object({
|
|
productId: z.number().positive(),
|
|
quantity: z.number().int().positive(),
|
|
price: z.number().nonnegative()
|
|
})
|
|
).min(1),
|
|
deliveryDate: z.date().min(new Date()),
|
|
email: z.string().email(),
|
|
phone: z.string().regex(/^\+?[0-9\s-]+$/),
|
|
notes: z.string().max(500).optional()
|
|
});
|
|
|
|
// Service with validation
|
|
class OrderService {
|
|
async createOrder(data: unknown): Promise<Order> {
|
|
try {
|
|
const validatedOrder = orderSchema.parse(data);
|
|
return await this.api.createOrder(validatedOrder);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
const message = extractZodErrorMessage(error);
|
|
throw new Error(message); // German error message for users
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Component usage
|
|
try {
|
|
await this.#orderService.createOrder(formData);
|
|
this.showSuccess('Order created');
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
// Display German validation errors
|
|
this.showError(error.message);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Error Type Hierarchy
|
|
|
|
```typescript
|
|
// Check error types with instanceof
|
|
try {
|
|
await someOperation();
|
|
} catch (error) {
|
|
if (error instanceof ResponseArgsError) {
|
|
// API returned error response
|
|
console.error('API Error:', error.responseArgs.message);
|
|
|
|
} else if (error instanceof PropertyIsEmptyError) {
|
|
// Required property was empty
|
|
console.error('Validation Error:', error.message);
|
|
|
|
} else if (error instanceof PropertyNullOrUndefinedError) {
|
|
// Required property was null/undefined
|
|
console.error('Null Check Error:', error.message);
|
|
|
|
} else if (error instanceof DataAccessError) {
|
|
// Generic data access error
|
|
console.error('Data Access Error:', error.code, error.message);
|
|
|
|
} else {
|
|
// Unknown error
|
|
console.error('Unexpected Error:', error);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Creating Custom Error Classes
|
|
|
|
```typescript
|
|
import { DataAccessError } from '@isa/common/data-access';
|
|
|
|
// Error with no additional data
|
|
class ResourceNotFoundError extends DataAccessError<'RESOURCE_NOT_FOUND'> {
|
|
constructor(resourceType: string, resourceId: string) {
|
|
super(
|
|
'RESOURCE_NOT_FOUND',
|
|
`${resourceType} with ID ${resourceId} not found`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Error with typed data
|
|
interface ValidationErrorData {
|
|
fields: Record<string, string[]>;
|
|
timestamp: Date;
|
|
}
|
|
|
|
class ValidationError extends DataAccessError<
|
|
'VALIDATION_ERROR',
|
|
ValidationErrorData
|
|
> {
|
|
constructor(fields: Record<string, string[]>) {
|
|
const fieldCount = Object.keys(fields).length;
|
|
super(
|
|
'VALIDATION_ERROR',
|
|
`Validation failed for ${fieldCount} field(s)`,
|
|
{ fields, timestamp: new Date() }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
throw new ResourceNotFoundError('User', '12345');
|
|
|
|
throw new ValidationError({
|
|
email: ['Invalid format', 'Already exists'],
|
|
password: ['Too short']
|
|
});
|
|
```
|
|
|
|
### Error Handling Best Practices
|
|
|
|
1. **Use Specific Error Types** - Choose the most specific error class
|
|
2. **Preserve Error Context** - Include relevant data in error instances
|
|
3. **Log Before Throwing** - Log detailed context, throw user-friendly errors
|
|
4. **Type Guards** - Use `instanceof` for error type checking
|
|
5. **Error Transformation** - Use `catchResponseArgsErrorPipe()` for HTTP errors
|
|
|
|
## RxJS Operators
|
|
|
|
### Operator Composition
|
|
|
|
```typescript
|
|
import {
|
|
takeUntilAborted,
|
|
takeUntilKeydownEscape,
|
|
catchResponseArgsErrorPipe
|
|
} from '@isa/common/data-access';
|
|
import { debounceTime, distinctUntilChanged, switchMap, retry } from 'rxjs/operators';
|
|
|
|
// Complex operator pipeline
|
|
searchProducts(query$: Observable<string>, signal: AbortSignal) {
|
|
return query$.pipe(
|
|
debounceTime(300), // Wait for typing to stop
|
|
distinctUntilChanged(), // Only when query changes
|
|
switchMap(query => // Switch to new search
|
|
this.api.search(query).pipe(
|
|
takeUntilAborted(signal), // Cancel with AbortSignal
|
|
takeUntilKeydownEscape(), // Cancel with Escape key
|
|
retry({ count: 2, delay: 1000 }) // Retry on failures
|
|
)
|
|
),
|
|
catchResponseArgsErrorPipe(), // Transform API errors
|
|
tap(results => this.#logger.info(`Found ${results.length} products`))
|
|
);
|
|
}
|
|
```
|
|
|
|
### Custom Operator Creation
|
|
|
|
```typescript
|
|
import { pipe } from 'rxjs';
|
|
import { takeUntilAborted, catchResponseArgsErrorPipe } from '@isa/common/data-access';
|
|
|
|
// Reusable operator factory
|
|
function withStandardErrorHandling<T>(signal: AbortSignal) {
|
|
return pipe(
|
|
takeUntilAborted(signal),
|
|
catchResponseArgsErrorPipe<T>(),
|
|
tap({
|
|
error: (error) => {
|
|
if (error instanceof ResponseArgsError) {
|
|
// Log API errors
|
|
console.error('API Error:', error.responseArgs);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
// Usage
|
|
this.api.getData().pipe(
|
|
withStandardErrorHandling(abortSignal),
|
|
map(data => processData(data))
|
|
);
|
|
```
|
|
|
|
## Batching Resource System
|
|
|
|
### Benefits of Batching
|
|
|
|
**Without Batching:**
|
|
```typescript
|
|
// 100 components each make separate API calls
|
|
Component 1: GET /api/stock?itemId=123
|
|
Component 2: GET /api/stock?itemId=456
|
|
Component 3: GET /api/stock?itemId=789
|
|
// ... 97 more requests
|
|
// Total: 100 HTTP requests
|
|
```
|
|
|
|
**With Batching:**
|
|
```typescript
|
|
// Single batched request after 250ms window
|
|
POST /api/stock/batch
|
|
Body: { itemIds: [123, 456, 789, ...] }
|
|
// Total: 1 HTTP request
|
|
```
|
|
|
|
### Batching Window Tuning
|
|
|
|
```typescript
|
|
// Fast batching (50ms) - for critical path, low latency
|
|
new MyResource(50);
|
|
|
|
// Balanced batching (250ms) - default, good for most cases
|
|
new MyResource(250);
|
|
|
|
// Aggressive batching (500ms) - for non-critical, high volume
|
|
new MyResource(500);
|
|
```
|
|
|
|
### Caching Strategy
|
|
|
|
The batching resource automatically caches results:
|
|
|
|
```typescript
|
|
// First request
|
|
const info1 = resource.resource(123); // Triggers API call
|
|
|
|
// Second request (before API returns)
|
|
const info2 = resource.resource(123); // Reuses same API call
|
|
|
|
// After API returns
|
|
const info3 = resource.resource(123); // Reads from cache (no API call)
|
|
|
|
// Clear cache for item
|
|
resource.reloadKeys([123]); // Next access triggers new API call
|
|
```
|
|
|
|
### Performance Characteristics
|
|
|
|
| Metric | Value | Notes |
|
|
|--------|-------|-------|
|
|
| Batch Window | 50-500ms | Configurable per resource |
|
|
| Cache Duration | Until refs = 0 | Automatic cleanup |
|
|
| Memory Overhead | ~1KB per item | Includes status tracking |
|
|
| Request Reduction | 50-100x | Typical in list views |
|
|
|
|
## Testing
|
|
|
|
The library uses **Jest** for testing.
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
# Run tests for this library
|
|
npx nx test common-data-access --skip-nx-cache
|
|
|
|
# Run tests with coverage
|
|
npx nx test common-data-access --code-coverage --skip-nx-cache
|
|
|
|
# Run tests in watch mode
|
|
npx nx test common-data-access --watch
|
|
```
|
|
|
|
### Test Coverage
|
|
|
|
The library includes comprehensive tests for:
|
|
|
|
- **Error Classes** - Instantiation, inheritance, type guards
|
|
- **Response Models** - Type correctness, optional properties
|
|
- **RxJS Operators** - Completion triggers, error transformation
|
|
- **Batching Resource** - Request batching, caching, cleanup
|
|
- **Zod Helpers** - Error message formatting, German translations
|
|
- **AbortSignal Helpers** - Keyboard cancellation
|
|
|
|
### Example Tests
|
|
|
|
#### Error Class Testing
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { DataAccessError, ResponseArgsError } from './errors';
|
|
|
|
describe('DataAccessError', () => {
|
|
it('should create error with code and message', () => {
|
|
class TestError extends DataAccessError<'TEST_ERROR'> {
|
|
constructor() {
|
|
super('TEST_ERROR', 'Test message');
|
|
}
|
|
}
|
|
|
|
const error = new TestError();
|
|
|
|
expect(error.code).toBe('TEST_ERROR');
|
|
expect(error.message).toBe('Test message');
|
|
expect(error.name).toBe('TestError');
|
|
});
|
|
|
|
it('should support instanceof checks', () => {
|
|
const error = new ResponseArgsError({
|
|
error: true,
|
|
message: 'API error'
|
|
});
|
|
|
|
expect(error instanceof ResponseArgsError).toBe(true);
|
|
expect(error instanceof DataAccessError).toBe(true);
|
|
expect(error instanceof Error).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
#### Batching Resource Testing
|
|
|
|
```typescript
|
|
import { TestBed } from '@angular/core/testing';
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { BatchingResource } from './batching-resource.base';
|
|
|
|
class TestResource extends BatchingResource<number, { ids: number[] }, { id: number; value: string }> {
|
|
fetchFn = vi.fn();
|
|
|
|
protected async fetchFn(params: { ids: number[] }, signal: AbortSignal) {
|
|
return this.fetchFn(params, signal);
|
|
}
|
|
|
|
protected buildParams(ids: number[]) {
|
|
return { ids };
|
|
}
|
|
|
|
protected getKeyFromResult(result: { id: number }) {
|
|
return result.id;
|
|
}
|
|
|
|
protected getCacheKey(id: number) {
|
|
return String(id);
|
|
}
|
|
|
|
protected extractKeysFromParams(params: { ids: number[] }) {
|
|
return params.ids;
|
|
}
|
|
}
|
|
|
|
describe('BatchingResource', () => {
|
|
let resource: TestResource;
|
|
|
|
beforeEach(() => {
|
|
TestBed.configureTestingModule({
|
|
providers: [TestResource]
|
|
});
|
|
resource = TestBed.inject(TestResource);
|
|
});
|
|
|
|
it('should batch multiple requests', async () => {
|
|
resource.fetchFn.mockResolvedValue([
|
|
{ id: 1, value: 'one' },
|
|
{ id: 2, value: 'two' }
|
|
]);
|
|
|
|
const ref1 = resource.resource(1);
|
|
const ref2 = resource.resource(2);
|
|
|
|
// Wait for batch window
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
|
|
expect(resource.fetchFn).toHaveBeenCalledTimes(1);
|
|
expect(resource.fetchFn).toHaveBeenCalledWith(
|
|
{ ids: [1, 2] },
|
|
expect.any(AbortSignal)
|
|
);
|
|
});
|
|
|
|
it('should cache results', async () => {
|
|
resource.fetchFn.mockResolvedValue([{ id: 1, value: 'one' }]);
|
|
|
|
const ref1 = resource.resource(1);
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
|
|
const ref2 = resource.resource(1); // Should use cache
|
|
|
|
expect(resource.fetchFn).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
## Architecture Notes
|
|
|
|
### Current Architecture
|
|
|
|
```
|
|
Domain Services
|
|
↓
|
|
Common Data Access (this library)
|
|
↓
|
|
├─→ Error Classes (structured error handling)
|
|
├─→ RxJS Operators (request control)
|
|
├─→ Response Models (API contracts)
|
|
├─→ Batching Resources (request optimization)
|
|
└─→ Helper Functions (utilities)
|
|
↓
|
|
External Dependencies (RxJS, Zod, Angular)
|
|
```
|
|
|
|
### Design Patterns
|
|
|
|
#### 1. Error Hierarchy Pattern
|
|
|
|
Structured error types with discriminated unions:
|
|
|
|
```typescript
|
|
// Type-safe error handling
|
|
function handleError(error: unknown) {
|
|
if (error instanceof ResponseArgsError) {
|
|
// Compiler knows: error.responseArgs exists
|
|
} else if (error instanceof PropertyIsEmptyError) {
|
|
// Compiler knows: error.code === 'PROPERTY_IS_EMPTY'
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2. Operator Composition Pattern
|
|
|
|
Composable RxJS operators for reusable logic:
|
|
|
|
```typescript
|
|
const withCancellation = (signal: AbortSignal) =>
|
|
pipe(
|
|
takeUntilAborted(signal),
|
|
catchResponseArgsErrorPipe()
|
|
);
|
|
```
|
|
|
|
#### 3. Abstract Resource Pattern
|
|
|
|
Template method pattern for batching resources:
|
|
|
|
```typescript
|
|
abstract class BatchingResource {
|
|
// Template method
|
|
resource(params) {
|
|
this.addKeys(params); // Common logic
|
|
return this.createRef(params); // Common logic
|
|
}
|
|
|
|
// Hook methods (implemented by subclasses)
|
|
protected abstract fetchFn(...);
|
|
protected abstract buildParams(...);
|
|
}
|
|
```
|
|
|
|
### Dependencies
|
|
|
|
#### Required Libraries
|
|
|
|
- `@angular/core` - Angular framework (signals, resources, DI)
|
|
- `rxjs` - Reactive programming
|
|
- `zod` - Runtime validation (peer dependency)
|
|
|
|
#### Path Alias
|
|
|
|
Import from: `@isa/common/data-access`
|
|
|
|
### Performance Considerations
|
|
|
|
1. **Batching Window Optimization** - Balance latency vs request reduction
|
|
2. **Cache Memory Management** - Reference counting prevents memory leaks
|
|
3. **Signal-Based Reactivity** - Minimal re-computation with Angular signals
|
|
4. **Operator Efficiency** - Custom operators avoid subscription overhead
|
|
5. **Error Object Reuse** - Error instances don't allocate large objects
|
|
|
|
### Future Enhancements
|
|
|
|
Potential improvements identified:
|
|
|
|
1. **Error Serialization** - Support for transmitting errors across workers
|
|
2. **Retry Strategies** - Built-in exponential backoff for transient failures
|
|
3. **Request Deduplication** - Prevent duplicate requests for same params
|
|
4. **Metrics Collection** - Track batch efficiency and cache hit rates
|
|
5. **Batch Priority Queues** - Prioritize critical requests over background
|
|
6. **Stale-While-Revalidate** - Serve cached data while fetching fresh data
|
|
7. **Offline Support** - Queue requests when network is unavailable
|
|
|
|
## License
|
|
|
|
Internal ISA Frontend library - not for external distribution.
|