Files
ISA-Frontend/libs/common/data-access/README-BATCHING-RESOURCE.md

304 lines
9.1 KiB
Markdown

# BatchingResource - Reusable Batching Pattern
## Overview
`BatchingResource` is a generic base class for creating smart batching resources that optimize multiple API requests by collecting params from multiple components and making a single batched request.
## Location
```
libs/common/data-access/src/lib/resources/batching-resource.base.ts
```
## Features
-**Automatic request batching** with configurable window
-**Smart caching** (including empty results to prevent infinite loops)
-**Reference counting** for automatic cleanup
-**Per-item loading/error status** tracking
-**Custom cache keys** support
-**TypeScript generics** for type safety
## How to Use
### 1. Create a Resource Class
Extend `BatchingResource` and implement the required abstract methods:
```typescript
import { Injectable, inject } from '@angular/core';
import { BatchingResource } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class MyDataResource extends BatchingResource<
number, // TParams: What users pass to resource()
MyApiParams, // TResourceParams: The type of batch API parameters
MyDataType // TResourceValue: The type of results
> {
#myService = inject(MyService);
constructor() {
super(100); // batchWindowMs (optional, defaults to 100ms)
}
// Required: Fetch data from your API
protected fetchFn(params: MyApiParams, signal: AbortSignal) {
return this.#myService.fetchData(params, signal);
}
// Required: Convert params list to API request format
protected buildParams(ids: number[]): MyApiParams {
return { ids };
}
// Required: Extract params from result for cache matching
protected getKeyFromResult(result: MyDataType): number | undefined {
return result.id;
}
// Required: Generate unique cache key from params
protected getCacheKey(id: number): string {
return String(id);
}
// Required: Extract params from resource params for status tracking
protected extractKeysFromParams(params: MyApiParams): number[] {
return params.ids;
}
}
```
### 2. Use in Components
```typescript
import { Component, computed, inject, input } from '@angular/core';
@Component({
selector: 'my-component',
template: `
@if (data.isLoading()) {
<div>Loading...</div>
} @else if (data.error()) {
<div>Error: {{ data.error() }}</div>
} @else {
<div>Data: {{ data.value()?.name }}</div>
}
`,
})
export class MyComponent {
#myDataResource = inject(MyDataResource);
itemId = input.required<number>();
// Automatically batches with other components
data = this.#myDataResource.resource(this.itemId);
// Use computed for derived values
name = computed(() => this.data.value()?.name ?? 'Unknown');
}
```
## API Reference
### Constructor
```typescript
constructor(batchWindowMs?: number) // default: 100
```
### Abstract Methods (Must Implement)
```typescript
// Fetch data from your API
protected abstract fetchFn(
params: TResourceParams,
abortSignal: AbortSignal
): Promise<TResourceValue[]>;
// Convert params list to API request format
protected abstract buildParams(params: TParams[]): TResourceParams;
// Extract params from result for cache matching
protected abstract getKeyFromResult(result: TResourceValue): TParams | undefined;
// Generate unique cache key from params
protected abstract getCacheKey(params: TParams): string;
// Extract params from resource params for status tracking
protected abstract extractKeysFromParams(params: TResourceParams): TParams[];
```
### Methods
#### `resource(params)`
Returns a `BatchingResourceRef` for specific params.
**Parameters:**
- `params: Signal<TParams> | TParams` - The params to fetch (can be a signal or static value)
**Returns:** `BatchingResourceRef<TResourceValue>`
#### `reload()`
Reloads all currently tracked items by clearing cache.
#### `reloadKeys(params: TParams[])`
Reloads specific items by clearing their cache entries.
### BatchingResourceRef
The object returned by `resource()`:
```typescript
{
value: Signal<TResourceValue | undefined>; // The cached result
hasValue: Signal<boolean>; // True if cached (even if empty)
isLoading: Signal<boolean>; // True while loading
error: Signal<unknown>; // Error if failed
status: Signal<ResourceStatus>; // 'idle' | 'loading' | 'resolved' | 'error'
reload: () => void; // Reload this specific item
}
```
## Advanced: Custom Cache Keys
For complex scenarios (like stock info with different warehouse IDs), encode all context in `TParams`:
```typescript
// Define TParams as an object containing all necessary context
export class StockResource extends BatchingResource<
{ itemId: number; warehouseId: string }, // TParams includes all context
FetchStockParams, // TResourceParams
StockInfo // TResourceValue
> {
#stockService = inject(StockService);
constructor() {
super(250);
}
protected fetchFn(params: FetchStockParams, signal: AbortSignal) {
return this.#stockService.fetchStockInfos(params, signal);
}
protected buildParams(
paramsList: { itemId: number; warehouseId: string }[]
): FetchStockParams {
return {
itemIds: paramsList.map(p => p.itemId),
warehouseId: paramsList[0].warehouseId, // All should have same warehouse
};
}
protected getKeyFromResult(stock: StockInfo) {
return {
itemId: stock.itemId,
warehouseId: stock.warehouseId
};
}
protected getCacheKey(params: { itemId: number; warehouseId: string }): string {
return `${params.warehouseId}-${params.itemId}`;
}
protected extractKeysFromParams(params: FetchStockParams) {
return params.itemIds.map(itemId => ({
itemId,
warehouseId: params.warehouseId
}));
}
}
// Then use the complete params object
const stockInfo = stockResource.resource({ itemId: 123, warehouseId: 'WH1' });
```
## How It Works
### Batching Process
1. **Component mounts** → Calls `resource(params)`
2. **Effect registers params** → Adds to pending batch queue
3. **Batch window expires** (default 100ms) → Pending params move to committed
4. **Resource triggers** → Fetches data for all committed params
5. **Results cached** → Both successful and empty results stored
6. **Component unmounts** → Reference count decremented, cleanup if zero
### Infinite Loop Prevention
The key innovation is **caching empty results**:
```typescript
// If API returns no data for params, we still cache it with undefined
if (!hasResult) {
cache.set(cacheKey, undefined);
}
```
This prevents the resource from thinking the params are "uncached" and re-requesting them infinitely.
## Example: Stock Info Resource (Real-World)
```typescript
@Injectable({ providedIn: 'root' })
export class StockInfoResource extends BatchingResource<
number, // TParams - Item IDs
FetchStockParams, // TResourceParams - { itemIds: number[] }
StockInfo // TResourceValue - { itemId: number, inStock: number, ... }
> {
#stockService = inject(RemissionStockService);
constructor() {
super(250); // Wait longer for more batching
}
protected 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;
}
}
// Usage in component
const stockInfo = inject(StockInfoResource).resource(itemId);
const inStock = computed(() => stockInfo.value()?.inStock ?? 0);
```
## Benefits Over Manual Implementation
**~500 lines** of complex batching logic → **~20 lines** of config
**Automatic** reference counting and cleanup
**Infinite loop prevention** built-in
**Type-safe** with TypeScript generics
**Reusable** across any domain (stock, pricing, availability, etc.)
**Tested** and battle-proven pattern
## Migration Guide
If you have an existing custom resource, extract these parts:
1. **Params type** (TParams): What do users pass to identify each item? (e.g., `number` for item IDs)
2. **Resource params type** (TResourceParams): What does your batch API accept? (e.g., `{ itemIds: number[] }`)
3. **Result type** (TResourceValue): What does your API return per item? (e.g., `StockInfo`)
4. **Service injection**: Your API service (e.g., `inject(MyService)`)
5. **Fetch method**: Your service method that calls the API
6. **Build params logic**: How to convert params list → resource params
7. **Get params from result logic**: How to extract the params from each result
8. **Cache key logic**: How to generate a unique string key from params
9. **Extract params logic**: How to get params list back from resource params (for status tracking)
Then implement these as abstract methods in your `BatchingResource` subclass!