@umituz/react-native-ai-fal-provider 2.1.3 → 2.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/domain/constants/default-models.constants.ts +13 -4
- package/src/domain/types/model-selection.types.ts +9 -5
- package/src/exports/infrastructure.ts +2 -9
- package/src/infrastructure/services/fal-models.service.ts +8 -7
- package/src/infrastructure/services/fal-provider.ts +9 -8
- package/src/infrastructure/services/fal-queue-operations.ts +8 -1
- package/src/infrastructure/services/nsfw-content-error.ts +1 -1
- package/src/infrastructure/services/request-store.ts +78 -22
- package/src/infrastructure/utils/collections/array-filters.util.ts +12 -1
- package/src/infrastructure/utils/collections/array-sorters.util.ts +34 -0
- package/src/infrastructure/utils/collections/index.ts +0 -1
- package/src/infrastructure/utils/cost-tracker.ts +3 -1
- package/src/infrastructure/utils/cost-tracking-executor.util.ts +14 -3
- package/src/infrastructure/utils/date-format.util.ts +13 -47
- package/src/infrastructure/utils/fal-storage.util.ts +40 -1
- package/src/infrastructure/utils/formatting.util.ts +3 -21
- package/src/infrastructure/utils/helpers/index.ts +0 -1
- package/src/infrastructure/utils/helpers/timing-helpers.util.ts +1 -79
- package/src/infrastructure/utils/image-helpers.util.ts +9 -5
- package/src/infrastructure/utils/index.ts +11 -50
- package/src/infrastructure/utils/input-preprocessor.util.ts +53 -6
- package/src/infrastructure/utils/input-validator.util.ts +71 -28
- package/src/infrastructure/utils/job-metadata/job-metadata-format.util.ts +26 -1
- package/src/infrastructure/utils/number-format.util.ts +27 -20
- package/src/infrastructure/utils/parsers/index.ts +0 -1
- package/src/infrastructure/utils/parsers/json-parsers.util.ts +19 -3
- package/src/infrastructure/utils/string-format.util.ts +0 -59
- package/src/infrastructure/utils/type-guards/validation-guards.util.ts +12 -0
- package/src/presentation/hooks/use-models.ts +12 -4
- package/src/infrastructure/utils/collections/array-reducers.util.ts +0 -67
- package/src/infrastructure/utils/helpers/function-helpers.util.ts +0 -25
- package/src/infrastructure/utils/parsers/number-helpers.util.ts +0 -19
package/package.json
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { FalModelType } from "../entities/fal.types";
|
|
7
|
-
import type { ModelType } from "../types/model-selection.types";
|
|
8
7
|
import { DEFAULT_TEXT_TO_IMAGE_MODELS } from "./models/text-to-image.models";
|
|
9
8
|
import { DEFAULT_TEXT_TO_VOICE_MODELS } from "./models/text-to-voice.models";
|
|
10
9
|
import { DEFAULT_TEXT_TO_VIDEO_MODELS } from "./models/text-to-video.models";
|
|
@@ -34,22 +33,28 @@ export interface FalModelConfig {
|
|
|
34
33
|
|
|
35
34
|
/**
|
|
36
35
|
* Default credit costs for each model type
|
|
36
|
+
* Supports all FalModelType values
|
|
37
37
|
*/
|
|
38
|
-
export const DEFAULT_CREDIT_COSTS: Record<
|
|
38
|
+
export const DEFAULT_CREDIT_COSTS: Record<FalModelType, number> = {
|
|
39
39
|
"text-to-image": 2,
|
|
40
40
|
"text-to-video": 20,
|
|
41
41
|
"image-to-video": 20,
|
|
42
42
|
"text-to-voice": 3,
|
|
43
|
+
"image-to-image": 2,
|
|
44
|
+
"text-to-text": 1,
|
|
43
45
|
} as const;
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
48
|
* Default model IDs for each model type
|
|
49
|
+
* Supports all FalModelType values
|
|
47
50
|
*/
|
|
48
|
-
export const DEFAULT_MODEL_IDS: Record<
|
|
51
|
+
export const DEFAULT_MODEL_IDS: Record<FalModelType, string> = {
|
|
49
52
|
"text-to-image": "fal-ai/flux/schnell",
|
|
50
53
|
"text-to-video": "fal-ai/minimax-video",
|
|
51
54
|
"image-to-video": "fal-ai/kling-video/v1.5/pro/image-to-video",
|
|
52
55
|
"text-to-voice": "fal-ai/playai/tts/v3",
|
|
56
|
+
"image-to-image": "fal-ai/flux/schnell",
|
|
57
|
+
"text-to-text": "fal-ai/flux/schnell",
|
|
53
58
|
} as const;
|
|
54
59
|
|
|
55
60
|
/**
|
|
@@ -90,10 +95,14 @@ export function getDefaultModelsByType(type: FalModelType): FalModelConfig[] {
|
|
|
90
95
|
|
|
91
96
|
/**
|
|
92
97
|
* Get default model for a type
|
|
98
|
+
* Returns the model marked as default, or the first model, or undefined if no models exist
|
|
93
99
|
*/
|
|
94
100
|
export function getDefaultModel(type: FalModelType): FalModelConfig | undefined {
|
|
95
101
|
const models = getDefaultModelsByType(type);
|
|
96
|
-
|
|
102
|
+
if (models.length === 0) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
return models.find((m) => m.isDefault) ?? models[0];
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
/**
|
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { FalModelConfig } from "../constants/default-models.constants";
|
|
7
|
+
import type { FalModelType } from "../entities/fal.types";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Public API model types (subset of FalModelType)
|
|
11
|
+
* UI components support these core model types
|
|
12
|
+
*/
|
|
13
|
+
export type ModelType = Extract<
|
|
14
|
+
FalModelType,
|
|
15
|
+
"text-to-image" | "text-to-video" | "image-to-video" | "text-to-voice"
|
|
16
|
+
>;
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* Configuration for model selection behavior
|
|
@@ -45,7 +45,6 @@ export {
|
|
|
45
45
|
uploadToFalStorage,
|
|
46
46
|
uploadMultipleToFalStorage,
|
|
47
47
|
formatNumber,
|
|
48
|
-
formatCurrency,
|
|
49
48
|
formatBytes,
|
|
50
49
|
formatDuration,
|
|
51
50
|
truncateText,
|
|
@@ -55,10 +54,7 @@ export {
|
|
|
55
54
|
isDefined,
|
|
56
55
|
removeNullish,
|
|
57
56
|
generateUniqueId,
|
|
58
|
-
debounce,
|
|
59
|
-
throttle,
|
|
60
57
|
sleep,
|
|
61
|
-
retry,
|
|
62
58
|
} from "../infrastructure/utils";
|
|
63
59
|
|
|
64
60
|
export {
|
|
@@ -89,8 +85,5 @@ export {
|
|
|
89
85
|
updateJobStatus,
|
|
90
86
|
} from "../infrastructure/utils";
|
|
91
87
|
|
|
92
|
-
export type {
|
|
93
|
-
|
|
94
|
-
IJobStorage,
|
|
95
|
-
InMemoryJobStorage,
|
|
96
|
-
} from "../infrastructure/utils";
|
|
88
|
+
export type { FalJobMetadata, IJobStorage } from "../infrastructure/utils";
|
|
89
|
+
export { InMemoryJobStorage } from "../infrastructure/utils";
|
|
@@ -55,28 +55,28 @@ export function getModelPricing(modelId: string): { freeUserCost: number; premiu
|
|
|
55
55
|
* Returns the model's free user cost if available, otherwise returns the default cost for the type
|
|
56
56
|
* NOTE: Use ?? instead of || to handle 0 values correctly (free models)
|
|
57
57
|
*/
|
|
58
|
-
export function getModelCreditCost(modelId: string, modelType:
|
|
58
|
+
export function getModelCreditCost(modelId: string, modelType: FalModelType): number {
|
|
59
59
|
const pricing = getModelPricing(modelId);
|
|
60
60
|
// CRITICAL: Use !== undefined instead of truthy check
|
|
61
61
|
// because freeUserCost can be 0 for free models!
|
|
62
62
|
if (pricing && pricing.freeUserCost !== undefined) {
|
|
63
63
|
return pricing.freeUserCost;
|
|
64
64
|
}
|
|
65
|
-
return DEFAULT_CREDIT_COSTS[modelType];
|
|
65
|
+
return DEFAULT_CREDIT_COSTS[modelType] ?? 0;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Get default credit cost for a model type
|
|
70
70
|
*/
|
|
71
|
-
export function getDefaultCreditCost(modelType:
|
|
72
|
-
return DEFAULT_CREDIT_COSTS[modelType];
|
|
71
|
+
export function getDefaultCreditCost(modelType: FalModelType): number {
|
|
72
|
+
return DEFAULT_CREDIT_COSTS[modelType] ?? 0;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Get default model ID for a model type
|
|
77
77
|
*/
|
|
78
|
-
export function getDefaultModelId(modelType:
|
|
79
|
-
return DEFAULT_MODEL_IDS[modelType];
|
|
78
|
+
export function getDefaultModelId(modelType: FalModelType): string {
|
|
79
|
+
return DEFAULT_MODEL_IDS[modelType] ?? "";
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
@@ -110,7 +110,8 @@ export function getModelSelectionData(
|
|
|
110
110
|
modelType: ModelType,
|
|
111
111
|
config?: ModelSelectionConfig
|
|
112
112
|
): ModelSelectionResult {
|
|
113
|
-
|
|
113
|
+
// ModelType is now a subset of FalModelType, so this cast is safe
|
|
114
|
+
const models = getModels(modelType);
|
|
114
115
|
const defaultCreditCost = config?.defaultCreditCost ?? getDefaultCreditCost(modelType);
|
|
115
116
|
const defaultModelId = config?.defaultModelId ?? getDefaultModelId(modelType);
|
|
116
117
|
const selectedModel = selectInitialModel(models, config, modelType);
|
|
@@ -126,18 +126,21 @@ export class FalProvider implements IAIProvider {
|
|
|
126
126
|
const abortController = new AbortController();
|
|
127
127
|
const tracker = this.costTracker;
|
|
128
128
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
let resolvePromise
|
|
132
|
-
let rejectPromise
|
|
129
|
+
// Create promise with resolvers using definite assignment
|
|
130
|
+
// This prevents race conditions and ensures type safety
|
|
131
|
+
let resolvePromise!: (value: T) => void;
|
|
132
|
+
let rejectPromise!: (error: unknown) => void;
|
|
133
133
|
const promise = new Promise<T>((resolve, reject) => {
|
|
134
134
|
resolvePromise = resolve;
|
|
135
135
|
rejectPromise = reject;
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
// Store promise immediately to enable request deduplication
|
|
139
|
+
// Multiple simultaneous calls with same params will get the same promise
|
|
138
140
|
storeRequest(key, { promise, abortController, createdAt: Date.now() });
|
|
139
141
|
|
|
140
142
|
// Execute the actual operation and resolve/reject the stored promise
|
|
143
|
+
// Note: This promise chain is not awaited - it runs independently
|
|
141
144
|
executeWithCostTracking({
|
|
142
145
|
tracker,
|
|
143
146
|
model,
|
|
@@ -146,12 +149,10 @@ export class FalProvider implements IAIProvider {
|
|
|
146
149
|
getRequestId: (res) => res.requestId ?? undefined,
|
|
147
150
|
})
|
|
148
151
|
.then((res) => {
|
|
149
|
-
resolvePromise
|
|
150
|
-
return res.result;
|
|
152
|
+
resolvePromise(res.result);
|
|
151
153
|
})
|
|
152
154
|
.catch((error) => {
|
|
153
|
-
rejectPromise
|
|
154
|
-
throw error;
|
|
155
|
+
rejectPromise(error);
|
|
155
156
|
})
|
|
156
157
|
.finally(() => {
|
|
157
158
|
try {
|
|
@@ -61,7 +61,14 @@ export async function getJobResult<T = unknown>(model: string, requestId: string
|
|
|
61
61
|
|
|
62
62
|
if (!result || typeof result !== 'object') {
|
|
63
63
|
throw new Error(
|
|
64
|
-
`Invalid FAL queue result for model ${model}, requestId ${requestId}`
|
|
64
|
+
`Invalid FAL queue result for model ${model}, requestId ${requestId}: Result is not an object`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Type guard: ensure result.data exists before casting
|
|
69
|
+
if (!('data' in result)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Invalid FAL queue result for model ${model}, requestId ${requestId}: Missing 'data' property`
|
|
65
72
|
);
|
|
66
73
|
}
|
|
67
74
|
|
|
@@ -11,12 +11,28 @@ export interface ActiveRequest<T = unknown> {
|
|
|
11
11
|
|
|
12
12
|
const STORE_KEY = "__FAL_PROVIDER_REQUESTS__";
|
|
13
13
|
const LOCK_KEY = "__FAL_PROVIDER_REQUESTS_LOCK__";
|
|
14
|
+
const TIMER_KEY = "__FAL_PROVIDER_CLEANUP_TIMER__";
|
|
14
15
|
type RequestStore = Map<string, ActiveRequest>;
|
|
15
16
|
|
|
16
|
-
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
17
17
|
const CLEANUP_INTERVAL = 60000; // 1 minute
|
|
18
18
|
const MAX_REQUEST_AGE = 300000; // 5 minutes
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Get cleanup timer from globalThis to survive hot reloads
|
|
22
|
+
*/
|
|
23
|
+
function getCleanupTimer(): ReturnType<typeof setInterval> | null {
|
|
24
|
+
const globalObj = globalThis as Record<string, unknown>;
|
|
25
|
+
return (globalObj[TIMER_KEY] as ReturnType<typeof setInterval>) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Set cleanup timer in globalThis to survive hot reloads
|
|
30
|
+
*/
|
|
31
|
+
function setCleanupTimer(timer: ReturnType<typeof setInterval> | null): void {
|
|
32
|
+
const globalObj = globalThis as Record<string, unknown>;
|
|
33
|
+
globalObj[TIMER_KEY] = timer;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
/**
|
|
21
37
|
* Simple lock mechanism to prevent concurrent access issues
|
|
22
38
|
* NOTE: This is not a true mutex but provides basic protection for React Native
|
|
@@ -65,13 +81,25 @@ export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined
|
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
export function storeRequest<T>(key: string, request: ActiveRequest<T>): void {
|
|
68
|
-
// Acquire lock for
|
|
69
|
-
|
|
84
|
+
// Acquire lock for consistent operation
|
|
85
|
+
// React Native is single-threaded, but this prevents re-entrancy issues
|
|
86
|
+
const maxRetries = 10;
|
|
70
87
|
let retries = 0;
|
|
71
88
|
|
|
89
|
+
// Spin-wait loop (synchronous)
|
|
90
|
+
// Note: Does NOT yield to event loop - tight loop
|
|
91
|
+
// In practice, this rarely loops due to single-threaded nature of React Native
|
|
72
92
|
while (!acquireLock() && retries < maxRetries) {
|
|
73
93
|
retries++;
|
|
74
|
-
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (retries >= maxRetries) {
|
|
97
|
+
// Lock acquisition failed - this shouldn't happen in normal operation
|
|
98
|
+
// Log warning but proceed anyway since RN is single-threaded
|
|
99
|
+
console.warn(
|
|
100
|
+
`[request-store] Failed to acquire lock after ${maxRetries} attempts for key: ${key}. ` +
|
|
101
|
+
'Proceeding anyway (safe in single-threaded environment)'
|
|
102
|
+
);
|
|
75
103
|
}
|
|
76
104
|
|
|
77
105
|
try {
|
|
@@ -84,6 +112,8 @@ export function storeRequest<T>(key: string, request: ActiveRequest<T>): void {
|
|
|
84
112
|
// Start automatic cleanup if not already running
|
|
85
113
|
startAutomaticCleanup();
|
|
86
114
|
} finally {
|
|
115
|
+
// Always release lock, even if we didn't successfully acquire it
|
|
116
|
+
// to prevent deadlocks
|
|
87
117
|
releaseLock();
|
|
88
118
|
}
|
|
89
119
|
}
|
|
@@ -93,9 +123,12 @@ export function removeRequest(key: string): void {
|
|
|
93
123
|
store.delete(key);
|
|
94
124
|
|
|
95
125
|
// Stop cleanup timer if store is empty
|
|
96
|
-
if (store.size === 0
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
if (store.size === 0) {
|
|
127
|
+
const timer = getCleanupTimer();
|
|
128
|
+
if (timer) {
|
|
129
|
+
clearInterval(timer);
|
|
130
|
+
setCleanupTimer(null);
|
|
131
|
+
}
|
|
99
132
|
}
|
|
100
133
|
}
|
|
101
134
|
|
|
@@ -107,9 +140,10 @@ export function cancelAllRequests(): void {
|
|
|
107
140
|
store.clear();
|
|
108
141
|
|
|
109
142
|
// Stop cleanup timer
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
143
|
+
const timer = getCleanupTimer();
|
|
144
|
+
if (timer) {
|
|
145
|
+
clearInterval(timer);
|
|
146
|
+
setCleanupTimer(null);
|
|
113
147
|
}
|
|
114
148
|
}
|
|
115
149
|
|
|
@@ -152,9 +186,12 @@ export function cleanupRequestStore(maxAge: number = MAX_REQUEST_AGE): number {
|
|
|
152
186
|
}
|
|
153
187
|
|
|
154
188
|
// Stop cleanup timer if store is empty
|
|
155
|
-
if (store.size === 0
|
|
156
|
-
|
|
157
|
-
|
|
189
|
+
if (store.size === 0) {
|
|
190
|
+
const timer = getCleanupTimer();
|
|
191
|
+
if (timer) {
|
|
192
|
+
clearInterval(timer);
|
|
193
|
+
setCleanupTimer(null);
|
|
194
|
+
}
|
|
158
195
|
}
|
|
159
196
|
|
|
160
197
|
return cleanedCount;
|
|
@@ -163,34 +200,53 @@ export function cleanupRequestStore(maxAge: number = MAX_REQUEST_AGE): number {
|
|
|
163
200
|
/**
|
|
164
201
|
* Start automatic cleanup of stale requests
|
|
165
202
|
* Runs periodically to prevent memory leaks
|
|
203
|
+
* Uses globalThis to survive hot reloads in React Native
|
|
166
204
|
*/
|
|
167
205
|
function startAutomaticCleanup(): void {
|
|
168
|
-
|
|
206
|
+
const existingTimer = getCleanupTimer();
|
|
207
|
+
if (existingTimer) {
|
|
169
208
|
return; // Already running
|
|
170
209
|
}
|
|
171
210
|
|
|
172
|
-
|
|
211
|
+
const timer = setInterval(() => {
|
|
173
212
|
const cleanedCount = cleanupRequestStore(MAX_REQUEST_AGE);
|
|
174
213
|
const store = getRequestStore();
|
|
175
214
|
|
|
176
215
|
// Stop timer if no more requests in store (prevents indefinite timer)
|
|
177
|
-
if (store.size === 0
|
|
178
|
-
|
|
179
|
-
|
|
216
|
+
if (store.size === 0) {
|
|
217
|
+
const currentTimer = getCleanupTimer();
|
|
218
|
+
if (currentTimer) {
|
|
219
|
+
clearInterval(currentTimer);
|
|
220
|
+
setCleanupTimer(null);
|
|
221
|
+
}
|
|
180
222
|
}
|
|
181
223
|
|
|
182
224
|
if (cleanedCount > 0) {
|
|
183
225
|
console.log(`[request-store] Cleaned up ${cleanedCount} stale request(s)`);
|
|
184
226
|
}
|
|
185
227
|
}, CLEANUP_INTERVAL);
|
|
228
|
+
|
|
229
|
+
setCleanupTimer(timer);
|
|
186
230
|
}
|
|
187
231
|
|
|
188
232
|
/**
|
|
189
|
-
* Stop automatic cleanup (typically on app shutdown)
|
|
233
|
+
* Stop automatic cleanup (typically on app shutdown or hot reload)
|
|
234
|
+
* Clears the global timer to prevent memory leaks
|
|
190
235
|
*/
|
|
191
236
|
export function stopAutomaticCleanup(): void {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
237
|
+
const timer = getCleanupTimer();
|
|
238
|
+
if (timer) {
|
|
239
|
+
clearInterval(timer);
|
|
240
|
+
setCleanupTimer(null);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Clean up any existing timer on module load to prevent leaks during hot reload
|
|
245
|
+
// This ensures old timers are cleared when the module is reloaded in development
|
|
246
|
+
if (typeof globalThis !== "undefined") {
|
|
247
|
+
const existingTimer = getCleanupTimer();
|
|
248
|
+
if (existingTimer) {
|
|
249
|
+
clearInterval(existingTimer);
|
|
250
|
+
setCleanupTimer(null);
|
|
195
251
|
}
|
|
196
252
|
}
|
|
@@ -26,6 +26,7 @@ export function filterByPredicate<T>(
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Filter array by time range (timestamp property)
|
|
29
|
+
* Validates that the timestamp property is actually a number before comparison
|
|
29
30
|
*/
|
|
30
31
|
export function filterByTimeRange<T>(
|
|
31
32
|
items: readonly T[],
|
|
@@ -34,7 +35,17 @@ export function filterByTimeRange<T>(
|
|
|
34
35
|
endTime: number
|
|
35
36
|
): T[] {
|
|
36
37
|
return items.filter((item) => {
|
|
37
|
-
const timestamp = item[timestampProperty]
|
|
38
|
+
const timestamp = item[timestampProperty];
|
|
39
|
+
|
|
40
|
+
// Type guard: ensure timestamp is actually a number
|
|
41
|
+
if (typeof timestamp !== 'number') {
|
|
42
|
+
console.warn(
|
|
43
|
+
`[array-filters] Skipping item with non-numeric timestamp property '${String(timestampProperty)}':`,
|
|
44
|
+
typeof timestamp
|
|
45
|
+
);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
return timestamp >= startTime && timestamp <= endTime;
|
|
39
50
|
});
|
|
40
51
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Sort array by date property (descending - newest first)
|
|
8
|
+
* Invalid dates are sorted to the end
|
|
8
9
|
*/
|
|
9
10
|
export function sortByDateDescending<T>(
|
|
10
11
|
items: readonly T[],
|
|
@@ -13,12 +14,19 @@ export function sortByDateDescending<T>(
|
|
|
13
14
|
return [...items].sort((a, b) => {
|
|
14
15
|
const timeA = new Date(a[dateProperty] as unknown as string).getTime();
|
|
15
16
|
const timeB = new Date(b[dateProperty] as unknown as string).getTime();
|
|
17
|
+
|
|
18
|
+
// Handle invalid dates - NaN should sort to end
|
|
19
|
+
if (isNaN(timeA) && isNaN(timeB)) return 0;
|
|
20
|
+
if (isNaN(timeA)) return 1; // a goes to end
|
|
21
|
+
if (isNaN(timeB)) return -1; // b goes to end
|
|
22
|
+
|
|
16
23
|
return timeB - timeA;
|
|
17
24
|
});
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
28
|
* Sort array by date property (ascending - oldest first)
|
|
29
|
+
* Invalid dates are sorted to the end
|
|
22
30
|
*/
|
|
23
31
|
export function sortByDateAscending<T>(
|
|
24
32
|
items: readonly T[],
|
|
@@ -27,12 +35,19 @@ export function sortByDateAscending<T>(
|
|
|
27
35
|
return [...items].sort((a, b) => {
|
|
28
36
|
const timeA = new Date(a[dateProperty] as unknown as string).getTime();
|
|
29
37
|
const timeB = new Date(b[dateProperty] as unknown as string).getTime();
|
|
38
|
+
|
|
39
|
+
// Handle invalid dates - NaN should sort to end
|
|
40
|
+
if (isNaN(timeA) && isNaN(timeB)) return 0;
|
|
41
|
+
if (isNaN(timeA)) return 1; // a goes to end
|
|
42
|
+
if (isNaN(timeB)) return -1; // b goes to end
|
|
43
|
+
|
|
30
44
|
return timeA - timeB;
|
|
31
45
|
});
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
/**
|
|
35
49
|
* Sort array by number property (descending)
|
|
50
|
+
* NaN and Infinity values are sorted to the end
|
|
36
51
|
*/
|
|
37
52
|
export function sortByNumberDescending<T>(
|
|
38
53
|
items: readonly T[],
|
|
@@ -41,12 +56,22 @@ export function sortByNumberDescending<T>(
|
|
|
41
56
|
return [...items].sort((a, b) => {
|
|
42
57
|
const numA = a[numberProperty] as unknown as number;
|
|
43
58
|
const numB = b[numberProperty] as unknown as number;
|
|
59
|
+
|
|
60
|
+
// Handle NaN and Infinity
|
|
61
|
+
const isAValid = isFinite(numA);
|
|
62
|
+
const isBValid = isFinite(numB);
|
|
63
|
+
|
|
64
|
+
if (!isAValid && !isBValid) return 0;
|
|
65
|
+
if (!isAValid) return 1; // a goes to end
|
|
66
|
+
if (!isBValid) return -1; // b goes to end
|
|
67
|
+
|
|
44
68
|
return numB - numA;
|
|
45
69
|
});
|
|
46
70
|
}
|
|
47
71
|
|
|
48
72
|
/**
|
|
49
73
|
* Sort array by number property (ascending)
|
|
74
|
+
* NaN and Infinity values are sorted to the end
|
|
50
75
|
*/
|
|
51
76
|
export function sortByNumberAscending<T>(
|
|
52
77
|
items: readonly T[],
|
|
@@ -55,6 +80,15 @@ export function sortByNumberAscending<T>(
|
|
|
55
80
|
return [...items].sort((a, b) => {
|
|
56
81
|
const numA = a[numberProperty] as unknown as number;
|
|
57
82
|
const numB = b[numberProperty] as unknown as number;
|
|
83
|
+
|
|
84
|
+
// Handle NaN and Infinity
|
|
85
|
+
const isAValid = isFinite(numA);
|
|
86
|
+
const isBValid = isFinite(numB);
|
|
87
|
+
|
|
88
|
+
if (!isAValid && !isBValid) return 0;
|
|
89
|
+
if (!isAValid) return 1; // a goes to end
|
|
90
|
+
if (!isBValid) return -1; // b goes to end
|
|
91
|
+
|
|
58
92
|
return numA - numB;
|
|
59
93
|
});
|
|
60
94
|
}
|
|
@@ -11,7 +11,9 @@ import type {
|
|
|
11
11
|
import { findModelById } from "../../domain/constants/default-models.constants";
|
|
12
12
|
import { filterByProperty, filterByTimeRange } from "./collection-filters.util";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
export type { GenerationCost } from "../../domain/entities/cost-tracking.types";
|
|
15
|
+
|
|
16
|
+
export interface CostSummary {
|
|
15
17
|
totalEstimatedCost: number;
|
|
16
18
|
totalActualCost: number;
|
|
17
19
|
currency: string;
|
|
@@ -36,15 +36,26 @@ export async function executeWithCostTracking<T>(
|
|
|
36
36
|
tracker.completeOperation(operationId, model, operation, requestId);
|
|
37
37
|
} catch (costError) {
|
|
38
38
|
// Cost tracking failure shouldn't break the operation
|
|
39
|
-
// Log for debugging
|
|
39
|
+
// Log for debugging and audit trail
|
|
40
|
+
console.error(
|
|
41
|
+
`[cost-tracking] Failed to complete cost tracking for ${operation} on ${model}:`,
|
|
42
|
+
costError instanceof Error ? costError.message : String(costError),
|
|
43
|
+
{ operationId, model, operation }
|
|
44
|
+
);
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
return result;
|
|
43
48
|
} catch (error) {
|
|
44
49
|
try {
|
|
45
50
|
tracker.failOperation(operationId);
|
|
46
|
-
} catch {
|
|
47
|
-
// Cost tracking cleanup failure on error path
|
|
51
|
+
} catch (failError) {
|
|
52
|
+
// Cost tracking cleanup failure on error path
|
|
53
|
+
// Log for debugging and audit trail
|
|
54
|
+
console.error(
|
|
55
|
+
`[cost-tracking] Failed to mark operation as failed for ${operation} on ${model}:`,
|
|
56
|
+
failError instanceof Error ? failError.message : String(failError),
|
|
57
|
+
{ operationId, model, operation }
|
|
58
|
+
);
|
|
48
59
|
}
|
|
49
60
|
throw error;
|
|
50
61
|
}
|
|
@@ -4,61 +4,27 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Validate that a date is valid
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return dateObj.toLocaleDateString(locale, {
|
|
12
|
-
year: "numeric",
|
|
13
|
-
month: "short",
|
|
14
|
-
day: "numeric",
|
|
15
|
-
});
|
|
9
|
+
function isValidDate(date: Date): boolean {
|
|
10
|
+
return date instanceof Date && !isNaN(date.getTime());
|
|
16
11
|
}
|
|
17
12
|
|
|
18
13
|
/**
|
|
19
|
-
* Format date
|
|
14
|
+
* Format date to locale string
|
|
15
|
+
* @throws {Error} if date is invalid
|
|
20
16
|
*/
|
|
21
|
-
export function
|
|
17
|
+
export function formatDate(date: Date | string, locale: string = "en-US"): string {
|
|
22
18
|
const dateObj = typeof date === "string" ? new Date(date) : date;
|
|
23
|
-
|
|
19
|
+
|
|
20
|
+
if (!isValidDate(dateObj)) {
|
|
21
|
+
const dateStr = typeof date === "string" ? date : date.toISOString();
|
|
22
|
+
throw new Error(`Invalid date: ${dateStr}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return dateObj.toLocaleDateString(locale, {
|
|
24
26
|
year: "numeric",
|
|
25
27
|
month: "short",
|
|
26
28
|
day: "numeric",
|
|
27
|
-
hour: "2-digit",
|
|
28
|
-
minute: "2-digit",
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Format relative time (e.g., "2 hours ago")
|
|
34
|
-
*/
|
|
35
|
-
export function formatRelativeTime(date: Date | string, locale: string = "en-US"): string {
|
|
36
|
-
const dateObj = typeof date === "string" ? new Date(date) : date;
|
|
37
|
-
const now = new Date();
|
|
38
|
-
const diffMs = now.getTime() - dateObj.getTime();
|
|
39
|
-
const diffSec = Math.floor(diffMs / 1000);
|
|
40
|
-
const diffMin = Math.floor(diffSec / 60);
|
|
41
|
-
const diffHour = Math.floor(diffMin / 60);
|
|
42
|
-
const diffDay = Math.floor(diffHour / 24);
|
|
43
|
-
|
|
44
|
-
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
45
|
-
|
|
46
|
-
if (diffSec < 60) {
|
|
47
|
-
return rtf.format(-diffSec, "second");
|
|
48
|
-
}
|
|
49
|
-
if (diffMin < 60) {
|
|
50
|
-
return rtf.format(-diffMin, "minute");
|
|
51
|
-
}
|
|
52
|
-
if (diffHour < 24) {
|
|
53
|
-
return rtf.format(-diffHour, "hour");
|
|
54
|
-
}
|
|
55
|
-
if (diffDay < 30) {
|
|
56
|
-
return rtf.format(-diffDay, "day");
|
|
57
|
-
}
|
|
58
|
-
if (diffDay < 365) {
|
|
59
|
-
const months = Math.floor(diffDay / 30);
|
|
60
|
-
return rtf.format(-months, "month");
|
|
61
|
-
}
|
|
62
|
-
const years = Math.floor(diffDay / 365);
|
|
63
|
-
return rtf.format(-years, "year");
|
|
64
|
-
}
|