mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
304 lines
9.1 KiB
Markdown
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!
|