mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 1879: Warenbegleitschein Übersicht und Details
Related work items: #5137, #5138
This commit is contained in:
277
libs/common/decorators/README.md
Normal file
277
libs/common/decorators/README.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Common Decorators Library
|
||||
|
||||
A collection of TypeScript decorators for common cross-cutting concerns in Angular applications.
|
||||
|
||||
## Installation
|
||||
|
||||
This library is already configured in the project's `tsconfig.base.json`. Import decorators using:
|
||||
|
||||
```typescript
|
||||
import { InFlight, InFlightWithKey, InFlightWithCache } from '@isa/common/decorators';
|
||||
```
|
||||
|
||||
## Available Decorators
|
||||
|
||||
### 🚀 InFlight Decorators
|
||||
|
||||
Prevent multiple simultaneous calls to the same async method. All concurrent calls receive the same Promise result.
|
||||
|
||||
#### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { InFlight } from '@isa/common/decorators';
|
||||
|
||||
@Injectable()
|
||||
class DataService {
|
||||
@InFlight()
|
||||
async fetchData(): Promise<Data> {
|
||||
// Even if called multiple times simultaneously,
|
||||
// only one API call will be made
|
||||
return await this.http.get<Data>('/api/data').toPromise();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Prevents duplicate API calls
|
||||
- Reduces server load
|
||||
- Improves application performance
|
||||
- All callers receive the same result
|
||||
|
||||
### 🔑 InFlightWithKey
|
||||
|
||||
Prevents duplicate calls while considering method arguments. Each unique set of arguments gets its own in-flight tracking.
|
||||
|
||||
```typescript
|
||||
import { InFlightWithKey } from '@isa/common/decorators';
|
||||
|
||||
@Injectable()
|
||||
class UserService {
|
||||
@InFlightWithKey({
|
||||
keyGenerator: (userId: string) => userId
|
||||
})
|
||||
async fetchUser(userId: string): Promise<User> {
|
||||
// Multiple calls with same userId share the same request
|
||||
// Different userIds can execute simultaneously
|
||||
return await this.http.get<User>(`/api/users/${userId}`).toPromise();
|
||||
}
|
||||
|
||||
@InFlightWithKey() // Uses JSON.stringify by default
|
||||
async searchUsers(query: string, page: number): Promise<User[]> {
|
||||
return await this.http.get<User[]>(`/api/users/search`, {
|
||||
params: { query, page: page.toString() }
|
||||
}).toPromise();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
- `keyGenerator?: (...args) => string` - Custom key generation function
|
||||
- If not provided, uses `JSON.stringify(args)` as the key
|
||||
|
||||
### 🗄️ InFlightWithCache
|
||||
|
||||
Combines in-flight request deduplication with result caching.
|
||||
|
||||
```typescript
|
||||
import { InFlightWithCache } from '@isa/common/decorators';
|
||||
|
||||
@Injectable()
|
||||
class ProductService {
|
||||
@InFlightWithCache({
|
||||
cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
keyGenerator: (productId: string) => productId
|
||||
})
|
||||
async getProduct(productId: string): Promise<Product> {
|
||||
// Results are cached for 5 minutes
|
||||
// Multiple calls within cache time return cached result
|
||||
return await this.http.get<Product>(`/api/products/${productId}`).toPromise();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
- `cacheTime?: number` - Cache duration in milliseconds
|
||||
- `keyGenerator?: (...args) => string` - Custom key generation function
|
||||
|
||||
## How It Works
|
||||
|
||||
### Memory Management
|
||||
|
||||
All decorators use `WeakMap` for memory efficiency:
|
||||
- Automatic garbage collection when instances are destroyed
|
||||
- No memory leaks
|
||||
- Per-instance state isolation
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Failed requests are not cached
|
||||
- In-flight tracking is cleaned up on both success and error
|
||||
- All concurrent callers receive the same error
|
||||
|
||||
### Thread Safety
|
||||
|
||||
- Decorators are instance-aware
|
||||
- Each service instance has its own in-flight tracking
|
||||
- No shared state between instances
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Solving Your Original Problem
|
||||
|
||||
```typescript
|
||||
// Before: Multiple simultaneous calls
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RemissionProductGroupService {
|
||||
async fetchProductGroups(): Promise<KeyValueStringAndString[]> {
|
||||
// Multiple calls = multiple API requests
|
||||
return await this.apiCall();
|
||||
}
|
||||
}
|
||||
|
||||
// After: Using InFlight decorator
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RemissionProductGroupService {
|
||||
@InFlight()
|
||||
async fetchProductGroups(): Promise<KeyValueStringAndString[]> {
|
||||
// Multiple simultaneous calls = single API request
|
||||
return await this.apiCall();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Scenarios
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
class OrderService {
|
||||
// Different cache times for different data types
|
||||
@InFlightWithCache({ cacheTime: 30 * 1000 }) // 30 seconds
|
||||
async getOrderStatus(orderId: string): Promise<OrderStatus> {
|
||||
return await this.http.get<OrderStatus>(`/api/orders/${orderId}/status`).toPromise();
|
||||
}
|
||||
|
||||
@InFlightWithCache({ cacheTime: 10 * 60 * 1000 }) // 10 minutes
|
||||
async getOrderHistory(customerId: string): Promise<Order[]> {
|
||||
return await this.http.get<Order[]>(`/api/customers/${customerId}/orders`).toPromise();
|
||||
}
|
||||
|
||||
// Custom key generation for complex parameters
|
||||
@InFlightWithKey({
|
||||
keyGenerator: (filter: OrderFilter) =>
|
||||
`${filter.status}-${filter.dateFrom}-${filter.dateTo}`
|
||||
})
|
||||
async searchOrders(filter: OrderFilter): Promise<Order[]> {
|
||||
return await this.http.post<Order[]>('/api/orders/search', filter).toPromise();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do
|
||||
|
||||
- Use `@InFlight()` for simple methods without parameters
|
||||
- Use `@InFlightWithKey()` for methods with parameters
|
||||
- Use `@InFlightWithCache()` for expensive operations with stable results
|
||||
- Provide custom `keyGenerator` for complex parameter objects
|
||||
- Set appropriate cache times based on data volatility
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
- Use on methods that return different results for the same input
|
||||
- Use excessively long cache times for dynamic data
|
||||
- Use on methods that have side effects (POST, PUT, DELETE)
|
||||
- Rely on argument order for default key generation
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- `InFlight`: Minimal memory overhead (one Promise per instance)
|
||||
- `InFlightWithKey`: Memory usage scales with unique parameter combinations
|
||||
- `InFlightWithCache`: Additional memory for cached results
|
||||
|
||||
### Cleanup
|
||||
|
||||
- In-flight requests are automatically cleaned up on completion
|
||||
- Cache entries are cleaned up on expiry
|
||||
- WeakMap ensures instances can be garbage collected
|
||||
|
||||
## Testing
|
||||
|
||||
The decorators are fully tested with comprehensive unit tests. Key test scenarios include:
|
||||
|
||||
- Multiple simultaneous calls deduplication
|
||||
- Error handling and cleanup
|
||||
- Cache expiration
|
||||
- Instance isolation
|
||||
- Key generation
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
npx nx test common-decorators
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Manual Implementation
|
||||
|
||||
```typescript
|
||||
// Before: Manual in-flight tracking
|
||||
class MyService {
|
||||
private inFlight: Promise<Data> | null = null;
|
||||
|
||||
async fetchData(): Promise<Data> {
|
||||
if (this.inFlight) {
|
||||
return this.inFlight;
|
||||
}
|
||||
|
||||
this.inFlight = this.doFetch();
|
||||
try {
|
||||
return await this.inFlight;
|
||||
} finally {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After: Using decorator
|
||||
class MyService {
|
||||
@InFlight()
|
||||
async fetchData(): Promise<Data> {
|
||||
return await this.doFetch();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### From RxJS shareReplay
|
||||
|
||||
```typescript
|
||||
// Before: RxJS approach
|
||||
class MyService {
|
||||
private data$ = this.http.get<Data>('/api/data').pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
);
|
||||
|
||||
getData(): Observable<Data> {
|
||||
return this.data$;
|
||||
}
|
||||
}
|
||||
|
||||
// After: Promise-based with decorator
|
||||
class MyService {
|
||||
@InFlightWithCache({ cacheTime: 5 * 60 * 1000 })
|
||||
async getData(): Promise<Data> {
|
||||
return await this.http.get<Data>('/api/data').toPromise();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new decorators:
|
||||
1. Add implementation in `src/lib/`
|
||||
2. Include comprehensive unit tests
|
||||
3. Update this documentation
|
||||
4. Export from `src/index.ts`
|
||||
34
libs/common/decorators/eslint.config.cjs
Normal file
34
libs/common/decorators/eslint.config.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
const nx = require('@nx/eslint-plugin');
|
||||
const baseConfig = require('../../../eslint.config.js');
|
||||
|
||||
module.exports = [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'common',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'common',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
20
libs/common/decorators/project.json
Normal file
20
libs/common/decorators/project.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "common-decorators",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/common/decorators/src",
|
||||
"prefix": "common",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "../../../coverage/libs/common/decorators"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/common/decorators/src/index.ts
Normal file
1
libs/common/decorators/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/in-flight.decorator';
|
||||
321
libs/common/decorators/src/lib/in-flight.decorator.spec.ts
Normal file
321
libs/common/decorators/src/lib/in-flight.decorator.spec.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { InFlight, InFlightWithKey, InFlightWithCache } from './in-flight.decorator';
|
||||
|
||||
describe('InFlight Decorators', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe('InFlight', () => {
|
||||
class TestService {
|
||||
callCount = 0;
|
||||
|
||||
@InFlight()
|
||||
async fetchData(delay = 100): Promise<string> {
|
||||
this.callCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return `result-${this.callCount}`;
|
||||
}
|
||||
|
||||
@InFlight()
|
||||
async fetchWithError(delay = 100): Promise<string> {
|
||||
this.callCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
throw new Error('Test error');
|
||||
}
|
||||
}
|
||||
|
||||
it('should prevent multiple simultaneous calls', async () => {
|
||||
const service = new TestService();
|
||||
|
||||
// Make three simultaneous calls
|
||||
const promise1 = service.fetchData();
|
||||
const promise2 = service.fetchData();
|
||||
const promise3 = service.fetchData();
|
||||
|
||||
// Advance timers to complete the async operation
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// All promises should resolve to the same value
|
||||
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||
|
||||
expect(result1).toBe('result-1');
|
||||
expect(result2).toBe('result-1');
|
||||
expect(result3).toBe('result-1');
|
||||
expect(service.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow subsequent calls after completion', async () => {
|
||||
const service = new TestService();
|
||||
|
||||
// First call
|
||||
const promise1 = service.fetchData();
|
||||
await vi.runAllTimersAsync();
|
||||
const result1 = await promise1;
|
||||
expect(result1).toBe('result-1');
|
||||
|
||||
// Second call after first completes
|
||||
const promise2 = service.fetchData();
|
||||
await vi.runAllTimersAsync();
|
||||
const result2 = await promise2;
|
||||
expect(result2).toBe('result-2');
|
||||
|
||||
expect(service.callCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle errors properly', async () => {
|
||||
const service = new TestService();
|
||||
|
||||
// Make multiple calls that will error
|
||||
const promise1 = service.fetchWithError();
|
||||
const promise2 = service.fetchWithError();
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Both should reject with the same error
|
||||
await expect(promise1).rejects.toThrow('Test error');
|
||||
await expect(promise2).rejects.toThrow('Test error');
|
||||
expect(service.callCount).toBe(1);
|
||||
|
||||
// Should allow new call after error
|
||||
const promise3 = service.fetchWithError();
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise3).rejects.toThrow('Test error');
|
||||
expect(service.callCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should maintain separate state per instance', async () => {
|
||||
const service1 = new TestService();
|
||||
const service2 = new TestService();
|
||||
|
||||
// Make simultaneous calls on different instances
|
||||
const promise1 = service1.fetchData();
|
||||
const promise2 = service2.fetchData();
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
// Each instance should have made its own call
|
||||
expect(result1).toBe('result-1');
|
||||
expect(result2).toBe('result-1');
|
||||
expect(service1.callCount).toBe(1);
|
||||
expect(service2.callCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InFlightWithKey', () => {
|
||||
class UserService {
|
||||
callCounts = new Map<string, number>();
|
||||
|
||||
@InFlightWithKey({
|
||||
keyGenerator: (userId: string) => userId
|
||||
})
|
||||
async fetchUser(userId: string, delay = 100): Promise<{ id: string; name: string }> {
|
||||
const count = (this.callCounts.get(userId) || 0) + 1;
|
||||
this.callCounts.set(userId, count);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return { id: userId, name: `User ${userId} - Call ${count}` };
|
||||
}
|
||||
|
||||
@InFlightWithKey()
|
||||
async fetchWithDefaultKey(param1: string, param2: number): Promise<string> {
|
||||
const key = `${param1}-${param2}`;
|
||||
const count = (this.callCounts.get(key) || 0) + 1;
|
||||
this.callCounts.set(key, count);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return `Result ${count}`;
|
||||
}
|
||||
}
|
||||
|
||||
it('should deduplicate calls with same key', async () => {
|
||||
const service = new UserService();
|
||||
|
||||
// Multiple calls with same userId
|
||||
const promise1 = service.fetchUser('user1');
|
||||
const promise2 = service.fetchUser('user1');
|
||||
const promise3 = service.fetchUser('user1');
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||
|
||||
expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
|
||||
expect(result2).toEqual(result1);
|
||||
expect(result3).toEqual(result1);
|
||||
expect(service.callCounts.get('user1')).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow simultaneous calls with different keys', async () => {
|
||||
const service = new UserService();
|
||||
|
||||
// Calls with different userIds
|
||||
const promise1 = service.fetchUser('user1');
|
||||
const promise2 = service.fetchUser('user2');
|
||||
const promise3 = service.fetchUser('user1'); // Duplicate of first
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||
|
||||
expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
|
||||
expect(result2).toEqual({ id: 'user2', name: 'User user2 - Call 1' });
|
||||
expect(result3).toEqual(result1); // Same as first call
|
||||
|
||||
expect(service.callCounts.get('user1')).toBe(1);
|
||||
expect(service.callCounts.get('user2')).toBe(1);
|
||||
});
|
||||
|
||||
it('should use JSON.stringify as default key generator', async () => {
|
||||
const service = new UserService();
|
||||
|
||||
// Multiple calls with same arguments
|
||||
const promise1 = service.fetchWithDefaultKey('test', 123);
|
||||
const promise2 = service.fetchWithDefaultKey('test', 123);
|
||||
|
||||
// Different arguments
|
||||
const promise3 = service.fetchWithDefaultKey('test', 456);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||
|
||||
expect(result1).toBe('Result 1');
|
||||
expect(result2).toBe('Result 1'); // Same as first
|
||||
expect(result3).toBe('Result 1'); // Different key, separate call
|
||||
|
||||
expect(service.callCounts.get('test-123')).toBe(1);
|
||||
expect(service.callCounts.get('test-456')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InFlightWithCache', () => {
|
||||
class DataService {
|
||||
callCount = 0;
|
||||
|
||||
@InFlightWithCache({
|
||||
cacheTime: 1000, // 1 second cache
|
||||
keyGenerator: (query: string) => query
|
||||
})
|
||||
async search(query: string): Promise<string[]> {
|
||||
this.callCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return [`result-${query}-${this.callCount}`];
|
||||
}
|
||||
|
||||
@InFlightWithCache({
|
||||
cacheTime: 500
|
||||
})
|
||||
async fetchWithExpiry(id: number): Promise<string> {
|
||||
this.callCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
return `data-${id}-${this.callCount}`;
|
||||
}
|
||||
}
|
||||
|
||||
it('should cache results for specified time', async () => {
|
||||
const service = new DataService();
|
||||
|
||||
// First call
|
||||
const promise1 = service.search('test');
|
||||
await vi.runAllTimersAsync();
|
||||
const result1 = await promise1;
|
||||
expect(result1).toEqual(['result-test-1']);
|
||||
expect(service.callCount).toBe(1);
|
||||
|
||||
// Second call within cache time - should return cached result
|
||||
const result2 = await service.search('test');
|
||||
expect(result2).toEqual(['result-test-1']);
|
||||
expect(service.callCount).toBe(1); // No new call
|
||||
|
||||
// Advance time past cache expiry
|
||||
vi.advanceTimersByTime(1100);
|
||||
|
||||
// Third call after cache expiry - should make new call
|
||||
const promise3 = service.search('test');
|
||||
await vi.runAllTimersAsync();
|
||||
const result3 = await promise3;
|
||||
expect(result3).toEqual(['result-test-2']);
|
||||
expect(service.callCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle in-flight deduplication with caching', async () => {
|
||||
const service = new DataService();
|
||||
|
||||
// Multiple simultaneous calls
|
||||
const promise1 = service.search('query1');
|
||||
const promise2 = service.search('query1');
|
||||
const promise3 = service.search('query1');
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||
|
||||
// All should get same result
|
||||
expect(result1).toEqual(['result-query1-1']);
|
||||
expect(result2).toEqual(result1);
|
||||
expect(result3).toEqual(result1);
|
||||
expect(service.callCount).toBe(1);
|
||||
|
||||
// Subsequent call should use cache
|
||||
const result4 = await service.search('query1');
|
||||
expect(result4).toEqual(['result-query1-1']);
|
||||
expect(service.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should clean up expired cache entries', async () => {
|
||||
const service = new DataService();
|
||||
|
||||
// Make a call
|
||||
const promise1 = service.fetchWithExpiry(1);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise1;
|
||||
|
||||
// Advance time past cache expiry
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
// Make another call - should not use expired cache
|
||||
service.callCount = 0; // Reset for clarity
|
||||
const promise2 = service.fetchWithExpiry(1);
|
||||
await vi.runAllTimersAsync();
|
||||
const result2 = await promise2;
|
||||
|
||||
expect(result2).toBe('data-1-1');
|
||||
expect(service.callCount).toBe(1); // New call was made
|
||||
});
|
||||
|
||||
it('should handle errors without caching them', async () => {
|
||||
class ErrorService {
|
||||
callCount = 0;
|
||||
|
||||
@InFlightWithCache({ cacheTime: 1000 })
|
||||
async fetchWithError(): Promise<string> {
|
||||
this.callCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
throw new Error('API Error');
|
||||
}
|
||||
}
|
||||
|
||||
const service = new ErrorService();
|
||||
|
||||
// First call that errors
|
||||
const promise1 = service.fetchWithError();
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise1).rejects.toThrow('API Error');
|
||||
expect(service.callCount).toBe(1);
|
||||
|
||||
// Second call should not use cache (errors aren't cached)
|
||||
const promise2 = service.fetchWithError();
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise2).rejects.toThrow('API Error');
|
||||
expect(service.callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
251
libs/common/decorators/src/lib/in-flight.decorator.ts
Normal file
251
libs/common/decorators/src/lib/in-flight.decorator.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* Decorator that prevents multiple simultaneous calls to the same async method.
|
||||
* All concurrent calls will receive the same Promise result.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyService {
|
||||
* @InFlight()
|
||||
* async fetchData(): Promise<Data> {
|
||||
* // This method will only execute once even if called multiple times simultaneously
|
||||
* return await api.getData();
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function InFlight<
|
||||
T extends (...args: any[]) => Promise<any>,
|
||||
>(): MethodDecorator {
|
||||
const inFlightMap = new WeakMap<object, Promise<any>>();
|
||||
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (
|
||||
this: any,
|
||||
...args: Parameters<T>
|
||||
): Promise<ReturnType<T>> {
|
||||
// Check if there's already an in-flight request for this instance
|
||||
const existingRequest = inFlightMap.get(this);
|
||||
if (existingRequest) {
|
||||
return existingRequest;
|
||||
}
|
||||
|
||||
// Create new request and store it
|
||||
const promise = originalMethod
|
||||
.apply(this, args)
|
||||
.then((result: any) => {
|
||||
// Clean up after successful completion
|
||||
inFlightMap.delete(this);
|
||||
return result;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// Clean up after error
|
||||
inFlightMap.delete(this);
|
||||
throw error;
|
||||
});
|
||||
|
||||
inFlightMap.set(this, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator that prevents multiple simultaneous calls to the same async method
|
||||
* while considering method arguments. Each unique set of arguments gets its own
|
||||
* in-flight tracking.
|
||||
*
|
||||
* @param options Configuration options for the decorator
|
||||
* @example
|
||||
* ```typescript
|
||||
* class UserService {
|
||||
* @InFlightWithKey({
|
||||
* keyGenerator: (userId: string) => userId
|
||||
* })
|
||||
* async fetchUser(userId: string): Promise<User> {
|
||||
* // Calls with different userIds can execute simultaneously
|
||||
* // Calls with the same userId will share the same promise
|
||||
* return await api.getUser(userId);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface InFlightWithKeyOptions<T extends (...args: any[]) => any> {
|
||||
/**
|
||||
* Generate a cache key from the method arguments.
|
||||
* If not provided, uses JSON.stringify on all arguments.
|
||||
*/
|
||||
keyGenerator?: (...args: Parameters<T>) => string;
|
||||
}
|
||||
|
||||
export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
|
||||
options: InFlightWithKeyOptions<T> = {},
|
||||
): MethodDecorator {
|
||||
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
|
||||
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (
|
||||
this: any,
|
||||
...args: Parameters<T>
|
||||
): Promise<ReturnType<T>> {
|
||||
// Initialize map for this instance if needed
|
||||
if (!inFlightMap.has(this)) {
|
||||
inFlightMap.set(this, new Map());
|
||||
}
|
||||
const instanceMap = inFlightMap.get(this)!;
|
||||
|
||||
// Generate cache key
|
||||
const key = options.keyGenerator
|
||||
? options.keyGenerator(...args)
|
||||
: JSON.stringify(args);
|
||||
|
||||
// Check if there's already an in-flight request for this key
|
||||
const existingRequest = instanceMap.get(key);
|
||||
if (existingRequest) {
|
||||
return existingRequest;
|
||||
}
|
||||
|
||||
// Create new request and store it
|
||||
const promise = originalMethod
|
||||
.apply(this, args)
|
||||
.then((result: any) => {
|
||||
// Clean up after successful completion
|
||||
instanceMap.delete(key);
|
||||
return result;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// Clean up after error
|
||||
instanceMap.delete(key);
|
||||
throw error;
|
||||
});
|
||||
|
||||
instanceMap.set(key, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator that prevents multiple simultaneous calls to the same async method
|
||||
* with additional caching capabilities.
|
||||
*
|
||||
* @param options Configuration options for the decorator
|
||||
* @example
|
||||
* ```typescript
|
||||
* class DataService {
|
||||
* @InFlightWithCache({
|
||||
* cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
* keyGenerator: (params: QueryParams) => params.query
|
||||
* })
|
||||
* async searchData(params: QueryParams): Promise<SearchResult> {
|
||||
* return await api.search(params);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface InFlightWithCacheOptions<T extends (...args: any[]) => any> {
|
||||
/**
|
||||
* Generate a cache key from the method arguments.
|
||||
* If not provided, uses JSON.stringify on all arguments.
|
||||
*/
|
||||
keyGenerator?: (...args: Parameters<T>) => string;
|
||||
|
||||
/**
|
||||
* Time in milliseconds to keep the result cached after completion.
|
||||
* If not provided, result is not cached after completion.
|
||||
*/
|
||||
cacheTime?: number;
|
||||
}
|
||||
|
||||
export function InFlightWithCache<T extends (...args: any[]) => Promise<any>>(
|
||||
options: InFlightWithCacheOptions<T> = {},
|
||||
): MethodDecorator {
|
||||
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
|
||||
const cacheMap = new WeakMap<
|
||||
object,
|
||||
Map<string, { result: any; expiry: number }>
|
||||
>();
|
||||
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (
|
||||
this: any,
|
||||
...args: Parameters<T>
|
||||
): Promise<ReturnType<T>> {
|
||||
// Initialize maps for this instance if needed
|
||||
if (!inFlightMap.has(this)) {
|
||||
inFlightMap.set(this, new Map());
|
||||
cacheMap.set(this, new Map());
|
||||
}
|
||||
const instanceInFlight = inFlightMap.get(this)!;
|
||||
const instanceCache = cacheMap.get(this)!;
|
||||
|
||||
// Generate cache key
|
||||
const key = options.keyGenerator
|
||||
? options.keyGenerator(...args)
|
||||
: JSON.stringify(args);
|
||||
|
||||
// Check cache first (if cacheTime is set)
|
||||
if (options.cacheTime) {
|
||||
const cached = instanceCache.get(key);
|
||||
if (cached && cached.expiry > Date.now()) {
|
||||
return Promise.resolve(cached.result);
|
||||
}
|
||||
// Clean up expired cache entry
|
||||
if (cached) {
|
||||
instanceCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's already an in-flight request
|
||||
const existingRequest = instanceInFlight.get(key);
|
||||
if (existingRequest) {
|
||||
return existingRequest;
|
||||
}
|
||||
|
||||
// Create new request
|
||||
const promise = originalMethod
|
||||
.apply(this, args)
|
||||
.then((result: any) => {
|
||||
// Cache result if cacheTime is set
|
||||
if (options.cacheTime) {
|
||||
instanceCache.set(key, {
|
||||
result,
|
||||
expiry: Date.now() + options.cacheTime,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
// Always clean up in-flight request
|
||||
instanceInFlight.delete(key);
|
||||
});
|
||||
|
||||
instanceInFlight.set(key, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
13
libs/common/decorators/src/test-setup.ts
Normal file
13
libs/common/decorators/src/test-setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-zone';
|
||||
|
||||
import {
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting,
|
||||
} from '@angular/platform-browser/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting(),
|
||||
);
|
||||
30
libs/common/decorators/tsconfig.json
Normal file
30
libs/common/decorators/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/common/decorators/tsconfig.lib.json
Normal file
27
libs/common/decorators/tsconfig.lib.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/test-setup.ts",
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
29
libs/common/decorators/tsconfig.spec.json
Normal file
29
libs/common/decorators/tsconfig.spec.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
27
libs/common/decorators/vite.config.mts
Normal file
27
libs/common/decorators/vite.config.mts
Normal file
@@ -0,0 +1,27 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/common/decorators',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/common/decorators',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user