@umituz/react-native-ai-fal-provider 2.0.20 → 2.0.22
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 +6 -6
- package/src/exports/infrastructure.ts +11 -1
- package/src/infrastructure/services/fal-provider-subscription.ts +1 -36
- package/src/infrastructure/services/fal-provider.ts +1 -1
- package/src/infrastructure/services/request-store.ts +38 -7
- package/src/infrastructure/utils/collection-filters.util.ts +171 -0
- package/src/infrastructure/utils/cost-tracker.ts +4 -15
- package/src/infrastructure/utils/data-parsers.util.ts +191 -0
- package/src/infrastructure/utils/error-mapper.ts +29 -0
- package/src/infrastructure/utils/formatting.util.ts +208 -0
- package/src/infrastructure/utils/general-helpers.util.ts +115 -10
- package/src/infrastructure/utils/index.ts +59 -1
- package/src/infrastructure/utils/job-metadata/job-metadata-queries.util.ts +11 -25
- package/src/infrastructure/utils/job-storage/job-storage-crud.util.ts +3 -6
- package/src/infrastructure/utils/job-storage/job-storage-queries.util.ts +3 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-fal-provider",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.22",
|
|
4
4
|
"description": "FAL AI provider for React Native - implements IAIProvider interface for unified AI generation",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@fal-ai/client": ">=0.6.0",
|
|
31
|
-
"react": ">=
|
|
32
|
-
"react-native": ">=0.
|
|
31
|
+
"react": ">=19.0.0",
|
|
32
|
+
"react-native": ">=0.81.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@fal-ai/client": ">=0.6.0"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@tanstack/query-async-storage-persister": "^5.66.7",
|
|
47
47
|
"@tanstack/react-query": "^5.66.7",
|
|
48
48
|
"@tanstack/react-query-persist-client": "^5.66.7",
|
|
49
|
-
"@types/react": "~
|
|
49
|
+
"@types/react": "~19.1.0",
|
|
50
50
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
51
51
|
"@typescript-eslint/parser": "^7.0.0",
|
|
52
52
|
"@umituz/react-native-auth": "*",
|
|
@@ -77,8 +77,8 @@
|
|
|
77
77
|
"expo-video": "^3.0.15",
|
|
78
78
|
"expo-web-browser": "^12.0.0",
|
|
79
79
|
"firebase": "^12.7.0",
|
|
80
|
-
"react": "
|
|
81
|
-
"react-native": "0.
|
|
80
|
+
"react": "19.1.0",
|
|
81
|
+
"react-native": "0.81.5",
|
|
82
82
|
"react-native-gesture-handler": "^2.30.0",
|
|
83
83
|
"react-native-purchases": "^9.7.5",
|
|
84
84
|
"react-native-reanimated": "^4.2.1",
|
|
@@ -15,6 +15,7 @@ export type { FalProviderType } from "../infrastructure/services";
|
|
|
15
15
|
export {
|
|
16
16
|
categorizeFalError,
|
|
17
17
|
mapFalError,
|
|
18
|
+
parseFalError,
|
|
18
19
|
isFalErrorRetryable,
|
|
19
20
|
buildSingleImageInput,
|
|
20
21
|
buildDualImageInput,
|
|
@@ -41,12 +42,21 @@ export {
|
|
|
41
42
|
isImageDataUri,
|
|
42
43
|
uploadToFalStorage,
|
|
43
44
|
uploadMultipleToFalStorage,
|
|
44
|
-
|
|
45
|
+
formatNumber,
|
|
46
|
+
formatCurrency,
|
|
47
|
+
formatBytes,
|
|
48
|
+
formatDuration,
|
|
49
|
+
truncateText,
|
|
45
50
|
truncatePrompt,
|
|
46
51
|
sanitizePrompt,
|
|
47
52
|
buildErrorMessage,
|
|
48
53
|
isDefined,
|
|
49
54
|
removeNullish,
|
|
55
|
+
generateUniqueId,
|
|
56
|
+
debounce,
|
|
57
|
+
throttle,
|
|
58
|
+
sleep,
|
|
59
|
+
retry,
|
|
50
60
|
} from "../infrastructure/utils";
|
|
51
61
|
|
|
52
62
|
export {
|
|
@@ -10,42 +10,7 @@ import { DEFAULT_FAL_CONFIG } from "./fal-provider.constants";
|
|
|
10
10
|
import { mapFalStatusToJobStatus } from "./fal-status-mapper";
|
|
11
11
|
import { validateNSFWContent } from "../validators/nsfw-validator";
|
|
12
12
|
import { NSFWContentError } from "./nsfw-content-error";
|
|
13
|
-
|
|
14
|
-
interface FalApiErrorDetail {
|
|
15
|
-
msg?: string;
|
|
16
|
-
type?: string;
|
|
17
|
-
loc?: string[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface FalApiError {
|
|
21
|
-
body?: { detail?: FalApiErrorDetail[] } | string;
|
|
22
|
-
message?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Parse FAL API error and extract user-friendly message
|
|
27
|
-
*/
|
|
28
|
-
function parseFalError(error: unknown): string {
|
|
29
|
-
const fallback = error instanceof Error ? error.message : String(error);
|
|
30
|
-
|
|
31
|
-
const falError = error as FalApiError;
|
|
32
|
-
if (!falError?.body) return fallback;
|
|
33
|
-
|
|
34
|
-
const body = typeof falError.body === "string"
|
|
35
|
-
? safeJsonParse(falError.body)
|
|
36
|
-
: falError.body;
|
|
37
|
-
|
|
38
|
-
const detail = body?.detail?.[0];
|
|
39
|
-
return detail?.msg ?? falError.message ?? fallback;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function safeJsonParse(str: string): { detail?: FalApiErrorDetail[] } | null {
|
|
43
|
-
try {
|
|
44
|
-
return JSON.parse(str) as { detail?: FalApiErrorDetail[] };
|
|
45
|
-
} catch {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
13
|
+
import { parseFalError } from "../utils/error-mapper";
|
|
49
14
|
|
|
50
15
|
/**
|
|
51
16
|
* Handle FAL subscription with timeout and cancellation
|
|
@@ -135,7 +135,7 @@ export class FalProvider implements IAIProvider {
|
|
|
135
135
|
}).then((res) => res.result).finally(() => removeRequest(key));
|
|
136
136
|
|
|
137
137
|
// Store promise immediately to prevent race condition
|
|
138
|
-
storeRequest(key, { promise, abortController });
|
|
138
|
+
storeRequest(key, { promise, abortController, createdAt: Date.now() });
|
|
139
139
|
return promise;
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
export interface ActiveRequest<T = unknown> {
|
|
7
7
|
promise: Promise<T>;
|
|
8
8
|
abortController: AbortController;
|
|
9
|
+
createdAt: number;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const STORE_KEY = "__FAL_PROVIDER_REQUESTS__";
|
|
@@ -40,7 +41,11 @@ export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export function storeRequest<T>(key: string, request: ActiveRequest<T>): void {
|
|
43
|
-
|
|
44
|
+
const requestWithTimestamp = {
|
|
45
|
+
...request,
|
|
46
|
+
createdAt: request.createdAt ?? Date.now(),
|
|
47
|
+
};
|
|
48
|
+
getRequestStore().set(key, requestWithTimestamp);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export function removeRequest(key: string): void {
|
|
@@ -63,15 +68,41 @@ export function hasActiveRequests(): boolean {
|
|
|
63
68
|
* Clean up completed/stale requests from the store
|
|
64
69
|
* Should be called periodically to prevent memory leaks
|
|
65
70
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
71
|
+
* @param maxAge - Maximum age in milliseconds (default: 5 minutes)
|
|
72
|
+
* @returns Number of requests cleaned up
|
|
68
73
|
*/
|
|
69
|
-
export function cleanupRequestStore(
|
|
74
|
+
export function cleanupRequestStore(maxAge: number = 300000): number {
|
|
70
75
|
const store = getRequestStore();
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
let cleanedCount = 0;
|
|
71
78
|
|
|
72
|
-
//
|
|
73
|
-
|
|
79
|
+
// Track stale requests
|
|
80
|
+
const staleKeys: string[] = [];
|
|
81
|
+
|
|
82
|
+
for (const [key, request] of store.entries()) {
|
|
83
|
+
const requestAge = now - request.createdAt;
|
|
84
|
+
|
|
85
|
+
// Clean up stale requests that exceed max age
|
|
86
|
+
if (requestAge > maxAge) {
|
|
87
|
+
staleKeys.push(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Remove stale requests
|
|
92
|
+
for (const key of staleKeys) {
|
|
93
|
+
const request = store.get(key);
|
|
94
|
+
if (request) {
|
|
95
|
+
request.abortController.abort();
|
|
96
|
+
store.delete(key);
|
|
97
|
+
cleanedCount++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Log warning if store size is still large after cleanup
|
|
74
102
|
if (store.size > 50) {
|
|
75
|
-
//
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
console.warn(`Request store size (${store.size}) exceeds threshold, potential memory leak detected`);
|
|
76
105
|
}
|
|
106
|
+
|
|
107
|
+
return cleanedCount;
|
|
77
108
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection Filter Utilities
|
|
3
|
+
* Common filter operations for arrays of objects
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Filter array by property value
|
|
8
|
+
*/
|
|
9
|
+
export function filterByProperty<T>(
|
|
10
|
+
items: readonly T[],
|
|
11
|
+
property: keyof T,
|
|
12
|
+
value: unknown
|
|
13
|
+
): T[] {
|
|
14
|
+
return items.filter((item) => item[property] === value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Filter array by predicate function
|
|
19
|
+
*/
|
|
20
|
+
export function filterByPredicate<T>(
|
|
21
|
+
items: readonly T[],
|
|
22
|
+
predicate: (item: T) => boolean
|
|
23
|
+
): T[] {
|
|
24
|
+
return items.filter(predicate);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Filter array by time range (timestamp property)
|
|
29
|
+
*/
|
|
30
|
+
export function filterByTimeRange<T>(
|
|
31
|
+
items: readonly T[],
|
|
32
|
+
timestampProperty: keyof T,
|
|
33
|
+
startTime: number,
|
|
34
|
+
endTime: number
|
|
35
|
+
): T[] {
|
|
36
|
+
return items.filter((item) => {
|
|
37
|
+
const timestamp = item[timestampProperty] as unknown as number;
|
|
38
|
+
return timestamp >= startTime && timestamp <= endTime;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Filter array by multiple property values (OR logic)
|
|
44
|
+
*/
|
|
45
|
+
export function filterByAnyProperty<T>(
|
|
46
|
+
items: readonly T[],
|
|
47
|
+
property: keyof T,
|
|
48
|
+
values: readonly unknown[]
|
|
49
|
+
): T[] {
|
|
50
|
+
const valueSet = new Set(values);
|
|
51
|
+
return items.filter((item) => valueSet.has(item[property]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sort array by date property (descending - newest first)
|
|
56
|
+
*/
|
|
57
|
+
export function sortByDateDescending<T>(
|
|
58
|
+
items: readonly T[],
|
|
59
|
+
dateProperty: keyof T
|
|
60
|
+
): T[] {
|
|
61
|
+
return [...items].sort((a, b) => {
|
|
62
|
+
const timeA = new Date(a[dateProperty] as unknown as string).getTime();
|
|
63
|
+
const timeB = new Date(b[dateProperty] as unknown as string).getTime();
|
|
64
|
+
return timeB - timeA;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sort array by date property (ascending - oldest first)
|
|
70
|
+
*/
|
|
71
|
+
export function sortByDateAscending<T>(
|
|
72
|
+
items: readonly T[],
|
|
73
|
+
dateProperty: keyof T
|
|
74
|
+
): T[] {
|
|
75
|
+
return [...items].sort((a, b) => {
|
|
76
|
+
const timeA = new Date(a[dateProperty] as unknown as string).getTime();
|
|
77
|
+
const timeB = new Date(b[dateProperty] as unknown as string).getTime();
|
|
78
|
+
return timeA - timeB;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sort array by number property (descending)
|
|
84
|
+
*/
|
|
85
|
+
export function sortByNumberDescending<T>(
|
|
86
|
+
items: readonly T[],
|
|
87
|
+
numberProperty: keyof T
|
|
88
|
+
): T[] {
|
|
89
|
+
return [...items].sort((a, b) => {
|
|
90
|
+
const numA = a[numberProperty] as unknown as number;
|
|
91
|
+
const numB = b[numberProperty] as unknown as number;
|
|
92
|
+
return numB - numA;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sort array by number property (ascending)
|
|
98
|
+
*/
|
|
99
|
+
export function sortByNumberAscending<T>(
|
|
100
|
+
items: readonly T[],
|
|
101
|
+
numberProperty: keyof T
|
|
102
|
+
): T[] {
|
|
103
|
+
return [...items].sort((a, b) => {
|
|
104
|
+
const numA = a[numberProperty] as unknown as number;
|
|
105
|
+
const numB = b[numberProperty] as unknown as number;
|
|
106
|
+
return numA - numB;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reduce array to sum of number property
|
|
112
|
+
*/
|
|
113
|
+
export function sumByProperty<T>(
|
|
114
|
+
items: readonly T[],
|
|
115
|
+
numberProperty: keyof T
|
|
116
|
+
): number {
|
|
117
|
+
return items.reduce((sum, item) => {
|
|
118
|
+
const value = item[numberProperty] as unknown as number;
|
|
119
|
+
return sum + (typeof value === "number" ? value : 0);
|
|
120
|
+
}, 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Group array by property value
|
|
125
|
+
*/
|
|
126
|
+
export function groupByProperty<T>(
|
|
127
|
+
items: readonly T[],
|
|
128
|
+
property: keyof T
|
|
129
|
+
): Map<unknown, T[]> {
|
|
130
|
+
const groups = new Map<unknown, T[]>();
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
const key = item[property];
|
|
133
|
+
const existing = groups.get(key);
|
|
134
|
+
if (existing) {
|
|
135
|
+
existing.push(item);
|
|
136
|
+
} else {
|
|
137
|
+
groups.set(key, [item]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return groups;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Chunk array into smaller arrays of specified size
|
|
145
|
+
*/
|
|
146
|
+
export function chunkArray<T>(items: readonly T[], chunkSize: number): T[][] {
|
|
147
|
+
const result: T[][] = [];
|
|
148
|
+
for (let i = 0; i < items.length; i += chunkSize) {
|
|
149
|
+
result.push([...items.slice(i, i + chunkSize)]);
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get distinct values of a property from array
|
|
156
|
+
*/
|
|
157
|
+
export function distinctByProperty<T>(
|
|
158
|
+
items: readonly T[],
|
|
159
|
+
property: keyof T
|
|
160
|
+
): unknown[] {
|
|
161
|
+
const seen = new Set<unknown>();
|
|
162
|
+
const result: unknown[] = [];
|
|
163
|
+
for (const item of items) {
|
|
164
|
+
const value = item[property];
|
|
165
|
+
if (!seen.has(value)) {
|
|
166
|
+
seen.add(value);
|
|
167
|
+
result.push(value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ModelCostInfo,
|
|
10
10
|
} from "../../domain/entities/cost-tracking.types";
|
|
11
11
|
import { findModelById } from "../../domain/constants/default-models.constants";
|
|
12
|
+
import { filterByProperty, filterByTimeRange } from "./collection-filters.util";
|
|
12
13
|
|
|
13
14
|
interface CostSummary {
|
|
14
15
|
totalEstimatedCost: number;
|
|
@@ -29,18 +30,6 @@ function calculateCostSummary(costs: GenerationCost[], currency: string): CostSu
|
|
|
29
30
|
);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function filterCostsByModel(costs: GenerationCost[], modelId: string): GenerationCost[] {
|
|
33
|
-
return costs.filter((cost) => cost.model === modelId);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function filterCostsByOperation(costs: GenerationCost[], operation: string): GenerationCost[] {
|
|
37
|
-
return costs.filter((cost) => cost.operation === operation);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function filterCostsByTimeRange(costs: GenerationCost[], startTime: number, endTime: number): GenerationCost[] {
|
|
41
|
-
return costs.filter((cost) => cost.timestamp >= startTime && cost.timestamp <= endTime);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
33
|
export class CostTracker {
|
|
45
34
|
private config: Required<CostTrackerConfig>;
|
|
46
35
|
private costHistory: GenerationCost[] = [];
|
|
@@ -161,14 +150,14 @@ export class CostTracker {
|
|
|
161
150
|
}
|
|
162
151
|
|
|
163
152
|
getCostsByModel(modelId: string): GenerationCost[] {
|
|
164
|
-
return
|
|
153
|
+
return filterByProperty(this.costHistory, "model", modelId);
|
|
165
154
|
}
|
|
166
155
|
|
|
167
156
|
getCostsByOperation(operation: string): GenerationCost[] {
|
|
168
|
-
return
|
|
157
|
+
return filterByProperty(this.costHistory, "operation", operation);
|
|
169
158
|
}
|
|
170
159
|
|
|
171
160
|
getCostsByTimeRange(startTime: number, endTime: number): GenerationCost[] {
|
|
172
|
-
return
|
|
161
|
+
return filterByTimeRange(this.costHistory, "timestamp", startTime, endTime);
|
|
173
162
|
}
|
|
174
163
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Parser Utilities
|
|
3
|
+
* Common patterns for parsing, validating, and transforming data
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Safely parse JSON with fallback
|
|
8
|
+
*/
|
|
9
|
+
export function safeJsonParse<T = unknown>(
|
|
10
|
+
data: string,
|
|
11
|
+
fallback: T
|
|
12
|
+
): T {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(data) as T;
|
|
15
|
+
} catch {
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Safely parse JSON with null fallback
|
|
22
|
+
*/
|
|
23
|
+
export function safeJsonParseOrNull<T = unknown>(data: string): T | null {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(data) as T;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Safely stringify object with fallback
|
|
33
|
+
*/
|
|
34
|
+
export function safeJsonStringify(
|
|
35
|
+
data: unknown,
|
|
36
|
+
fallback: string
|
|
37
|
+
): string {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(data);
|
|
40
|
+
} catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if string is valid JSON
|
|
47
|
+
*/
|
|
48
|
+
export function isValidJson(data: string): boolean {
|
|
49
|
+
try {
|
|
50
|
+
JSON.parse(data);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate object structure
|
|
59
|
+
*/
|
|
60
|
+
export function validateObjectStructure<T extends Record<string, unknown>>(
|
|
61
|
+
data: unknown,
|
|
62
|
+
requiredKeys: readonly (keyof T)[]
|
|
63
|
+
): data is T {
|
|
64
|
+
if (!data || typeof data !== "object") {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const key of requiredKeys) {
|
|
69
|
+
if (!(key in data)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate array of objects
|
|
79
|
+
*/
|
|
80
|
+
export function validateObjectArray<T>(
|
|
81
|
+
data: unknown,
|
|
82
|
+
validator: (item: unknown) => item is T
|
|
83
|
+
): data is T[] {
|
|
84
|
+
if (!Array.isArray(data)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return data.every(validator);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse number with fallback
|
|
93
|
+
*/
|
|
94
|
+
export function parseNumber(value: unknown, fallback: number): number {
|
|
95
|
+
if (typeof value === "number") {
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof value === "string") {
|
|
100
|
+
const parsed = Number.parseFloat(value);
|
|
101
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse boolean with fallback
|
|
109
|
+
*/
|
|
110
|
+
export function parseBoolean(value: unknown, fallback: boolean): boolean {
|
|
111
|
+
if (typeof value === "boolean") {
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof value === "string") {
|
|
116
|
+
const lower = value.toLowerCase().trim();
|
|
117
|
+
if (lower === "true" || lower === "yes" || lower === "1") {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (lower === "false" || lower === "no" || lower === "0") {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof value === "number") {
|
|
126
|
+
return value !== 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return fallback;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Clamp number between min and max
|
|
134
|
+
*/
|
|
135
|
+
export function clampNumber(value: number, min: number, max: number): number {
|
|
136
|
+
return Math.min(Math.max(value, min), max);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Round to decimal places
|
|
141
|
+
*/
|
|
142
|
+
export function roundToDecimals(value: number, decimals: number): number {
|
|
143
|
+
const multiplier = Math.pow(10, decimals);
|
|
144
|
+
return Math.round(value * multiplier) / multiplier;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Deep clone object using JSON
|
|
149
|
+
*/
|
|
150
|
+
export function deepClone<T>(data: T): T {
|
|
151
|
+
return safeJsonParse(safeJsonStringify(data, "{}"), data);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Merge objects with later objects overriding earlier ones
|
|
156
|
+
*/
|
|
157
|
+
export function mergeObjects<T extends Record<string, unknown>>(
|
|
158
|
+
...objects: Partial<T>[]
|
|
159
|
+
): T {
|
|
160
|
+
return Object.assign({}, ...objects) as T;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Pick specified properties from object
|
|
165
|
+
*/
|
|
166
|
+
export function pickProperties<T extends Record<string, unknown>, K extends keyof T>(
|
|
167
|
+
obj: T,
|
|
168
|
+
keys: readonly K[]
|
|
169
|
+
): Pick<T, K> {
|
|
170
|
+
const result = {} as Pick<T, K>;
|
|
171
|
+
for (const key of keys) {
|
|
172
|
+
if (key in obj) {
|
|
173
|
+
result[key] = obj[key];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Omit specified properties from object
|
|
181
|
+
*/
|
|
182
|
+
export function omitProperties<T extends Record<string, unknown>, K extends keyof T>(
|
|
183
|
+
obj: T,
|
|
184
|
+
keys: readonly K[]
|
|
185
|
+
): Omit<T, K> {
|
|
186
|
+
const result = { ...obj };
|
|
187
|
+
for (const key of keys) {
|
|
188
|
+
delete result[key];
|
|
189
|
+
}
|
|
190
|
+
return result as Omit<T, K>;
|
|
191
|
+
}
|
|
@@ -4,14 +4,43 @@
|
|
|
4
4
|
|
|
5
5
|
import type { FalErrorInfo } from "../../domain/entities/error.types";
|
|
6
6
|
import { categorizeFalError } from "./error-categorizer";
|
|
7
|
+
import { safeJsonParseOrNull } from "./data-parsers.util";
|
|
7
8
|
|
|
8
9
|
const STATUS_CODES = ["400", "401", "402", "403", "404", "422", "429", "500", "502", "503", "504"];
|
|
9
10
|
|
|
11
|
+
interface FalApiErrorDetail {
|
|
12
|
+
msg?: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
loc?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FalApiError {
|
|
18
|
+
body?: { detail?: FalApiErrorDetail[] } | string;
|
|
19
|
+
message?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
function extractStatusCode(errorString: string): number | undefined {
|
|
11
23
|
const code = STATUS_CODES.find((c) => errorString.includes(c));
|
|
12
24
|
return code ? parseInt(code, 10) : undefined;
|
|
13
25
|
}
|
|
14
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Parse FAL API error and extract user-friendly message
|
|
29
|
+
*/
|
|
30
|
+
export function parseFalError(error: unknown): string {
|
|
31
|
+
const fallback = error instanceof Error ? error.message : String(error);
|
|
32
|
+
|
|
33
|
+
const falError = error as FalApiError;
|
|
34
|
+
if (!falError?.body) return fallback;
|
|
35
|
+
|
|
36
|
+
const body = typeof falError.body === "string"
|
|
37
|
+
? safeJsonParseOrNull<{ detail?: FalApiErrorDetail[] }>(falError.body)
|
|
38
|
+
: falError.body;
|
|
39
|
+
|
|
40
|
+
const detail = body?.detail?.[0];
|
|
41
|
+
return detail?.msg ?? falError.message ?? fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
export function mapFalError(error: unknown): FalErrorInfo {
|
|
16
45
|
const category = categorizeFalError(error);
|
|
17
46
|
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting Utilities
|
|
3
|
+
* Common formatting functions for display and data presentation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format number with decimal places
|
|
8
|
+
*/
|
|
9
|
+
export function formatNumber(value: number, decimals: number = 2): string {
|
|
10
|
+
if (Number.isNaN(value) || !Number.isFinite(value)) {
|
|
11
|
+
return "0";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Return integer if no decimal part
|
|
15
|
+
if (value % 1 === 0) {
|
|
16
|
+
return value.toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return value.toFixed(decimals);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format currency amount
|
|
24
|
+
*/
|
|
25
|
+
export function formatCurrency(amount: number, currency: string = "USD"): string {
|
|
26
|
+
const formatted = formatNumber(amount, 2);
|
|
27
|
+
return `${currency} ${formatted}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format bytes to human-readable size
|
|
32
|
+
*/
|
|
33
|
+
export function formatBytes(bytes: number, decimals: number = 2): string {
|
|
34
|
+
if (bytes === 0) return "0 Bytes";
|
|
35
|
+
|
|
36
|
+
const k = 1024;
|
|
37
|
+
const dm = decimals < 0 ? 0 : decimals;
|
|
38
|
+
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
|
|
39
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
40
|
+
|
|
41
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format duration in milliseconds to human-readable string
|
|
46
|
+
*/
|
|
47
|
+
export function formatDuration(milliseconds: number): string {
|
|
48
|
+
if (milliseconds < 1000) {
|
|
49
|
+
return `${milliseconds}ms`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const seconds = Math.floor(milliseconds / 1000);
|
|
53
|
+
if (seconds < 60) {
|
|
54
|
+
return `${seconds}s`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const minutes = Math.floor(seconds / 60);
|
|
58
|
+
const remainingSeconds = seconds % 60;
|
|
59
|
+
if (minutes < 60) {
|
|
60
|
+
return remainingSeconds > 0
|
|
61
|
+
? `${minutes}m ${remainingSeconds}s`
|
|
62
|
+
: `${minutes}m`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hours = Math.floor(minutes / 60);
|
|
66
|
+
const remainingMinutes = minutes % 60;
|
|
67
|
+
return remainingMinutes > 0
|
|
68
|
+
? `${hours}h ${remainingMinutes}m`
|
|
69
|
+
: `${hours}h`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Format percentage
|
|
74
|
+
*/
|
|
75
|
+
export function formatPercentage(value: number, decimals: number = 1): string {
|
|
76
|
+
if (value < 0) return "0%";
|
|
77
|
+
if (value > 100) return "100%";
|
|
78
|
+
return `${formatNumber(value, decimals)}%`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Format date to locale string
|
|
83
|
+
*/
|
|
84
|
+
export function formatDate(date: Date | string, locale: string = "en-US"): string {
|
|
85
|
+
const dateObj = typeof date === "string" ? new Date(date) : date;
|
|
86
|
+
return dateObj.toLocaleDateString(locale, {
|
|
87
|
+
year: "numeric",
|
|
88
|
+
month: "short",
|
|
89
|
+
day: "numeric",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Format date and time to locale string
|
|
95
|
+
*/
|
|
96
|
+
export function formatDateTime(date: Date | string, locale: string = "en-US"): string {
|
|
97
|
+
const dateObj = typeof date === "string" ? new Date(date) : date;
|
|
98
|
+
return dateObj.toLocaleString(locale, {
|
|
99
|
+
year: "numeric",
|
|
100
|
+
month: "short",
|
|
101
|
+
day: "numeric",
|
|
102
|
+
hour: "2-digit",
|
|
103
|
+
minute: "2-digit",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format relative time (e.g., "2 hours ago")
|
|
109
|
+
*/
|
|
110
|
+
export function formatRelativeTime(date: Date | string, locale: string = "en-US"): string {
|
|
111
|
+
const dateObj = typeof date === "string" ? new Date(date) : date;
|
|
112
|
+
const now = new Date();
|
|
113
|
+
const diffMs = now.getTime() - dateObj.getTime();
|
|
114
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
115
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
116
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
117
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
118
|
+
|
|
119
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
120
|
+
|
|
121
|
+
if (diffSec < 60) {
|
|
122
|
+
return rtf.format(-diffSec, "second");
|
|
123
|
+
}
|
|
124
|
+
if (diffMin < 60) {
|
|
125
|
+
return rtf.format(-diffMin, "minute");
|
|
126
|
+
}
|
|
127
|
+
if (diffHour < 24) {
|
|
128
|
+
return rtf.format(-diffHour, "hour");
|
|
129
|
+
}
|
|
130
|
+
if (diffDay < 30) {
|
|
131
|
+
return rtf.format(-diffDay, "day");
|
|
132
|
+
}
|
|
133
|
+
if (diffDay < 365) {
|
|
134
|
+
const months = Math.floor(diffDay / 30);
|
|
135
|
+
return rtf.format(-months, "month");
|
|
136
|
+
}
|
|
137
|
+
const years = Math.floor(diffDay / 365);
|
|
138
|
+
return rtf.format(-years, "year");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Truncate text with ellipsis
|
|
143
|
+
*/
|
|
144
|
+
export function truncateText(text: string, maxLength: number): string {
|
|
145
|
+
if (text.length <= maxLength) {
|
|
146
|
+
return text;
|
|
147
|
+
}
|
|
148
|
+
return text.slice(0, maxLength - 3) + "...";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Capitalize first letter of string
|
|
153
|
+
*/
|
|
154
|
+
export function capitalize(text: string): string {
|
|
155
|
+
if (!text) return text;
|
|
156
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Convert string to title case
|
|
161
|
+
*/
|
|
162
|
+
export function toTitleCase(text: string): string {
|
|
163
|
+
return text
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.split(" ")
|
|
166
|
+
.map((word) => capitalize(word))
|
|
167
|
+
.join(" ");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Convert string to slug
|
|
172
|
+
*/
|
|
173
|
+
export function toSlug(text: string): string {
|
|
174
|
+
return text
|
|
175
|
+
.toLowerCase()
|
|
176
|
+
.trim()
|
|
177
|
+
.replace(/[^\w\s-]/g, "")
|
|
178
|
+
.replace(/[\s_-]+/g, "-")
|
|
179
|
+
.replace(/^-+|-+$/g, "");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Format list of items with conjunction
|
|
184
|
+
*/
|
|
185
|
+
export function formatList(items: readonly string[], conjunction: string = "and"): string {
|
|
186
|
+
if (items.length === 0) return "";
|
|
187
|
+
if (items.length === 1) return items[0] ?? "";
|
|
188
|
+
if (items.length === 2) return items.join(` ${conjunction} `);
|
|
189
|
+
|
|
190
|
+
const allButLast = items.slice(0, -1);
|
|
191
|
+
const last = items[items.length - 1];
|
|
192
|
+
return `${allButLast.join(", ")}, ${conjunction} ${last}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Pluralize word based on count
|
|
197
|
+
*/
|
|
198
|
+
export function pluralize(word: string, count: number): string {
|
|
199
|
+
if (count === 1) return word;
|
|
200
|
+
return `${word}s`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Format count with plural word
|
|
205
|
+
*/
|
|
206
|
+
export function formatCount(word: string, count: number): string {
|
|
207
|
+
return `${count} ${pluralize(word, count)}`;
|
|
208
|
+
}
|
|
@@ -3,16 +3,6 @@
|
|
|
3
3
|
* Common utility functions
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Format credit cost for display
|
|
8
|
-
*/
|
|
9
|
-
export function formatCreditCost(cost: number): string {
|
|
10
|
-
if (cost % 1 === 0) {
|
|
11
|
-
return cost.toString();
|
|
12
|
-
}
|
|
13
|
-
return cost.toFixed(2);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
6
|
/**
|
|
17
7
|
* Build error message with context
|
|
18
8
|
*/
|
|
@@ -43,3 +33,118 @@ export function removeNullish<T extends Record<string, unknown>>(
|
|
|
43
33
|
Object.entries(obj).filter(([_, value]) => isDefined(value))
|
|
44
34
|
) as Partial<T>;
|
|
45
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate unique ID
|
|
39
|
+
*/
|
|
40
|
+
export function generateUniqueId(prefix: string = ""): string {
|
|
41
|
+
const timestamp = Date.now().toString(36);
|
|
42
|
+
const randomStr = Math.random().toString(36).substring(2, 9);
|
|
43
|
+
return prefix ? `${prefix}_${timestamp}${randomStr}` : `${timestamp}${randomStr}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create debounced function
|
|
48
|
+
*/
|
|
49
|
+
export function debounce<T extends (...args: never[]) => void>(
|
|
50
|
+
func: T,
|
|
51
|
+
wait: number
|
|
52
|
+
): (...args: Parameters<T>) => void {
|
|
53
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
54
|
+
|
|
55
|
+
return function debounced(...args: Parameters<T>) {
|
|
56
|
+
if (timeoutId) {
|
|
57
|
+
clearTimeout(timeoutId);
|
|
58
|
+
}
|
|
59
|
+
timeoutId = setTimeout(() => {
|
|
60
|
+
func(...args);
|
|
61
|
+
}, wait);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create throttled function
|
|
67
|
+
*/
|
|
68
|
+
export function throttle<T extends (...args: never[]) => void>(
|
|
69
|
+
func: T,
|
|
70
|
+
limit: number
|
|
71
|
+
): (...args: Parameters<T>) => void {
|
|
72
|
+
let inThrottle = false;
|
|
73
|
+
|
|
74
|
+
return function throttled(...args: Parameters<T>) {
|
|
75
|
+
if (!inThrottle) {
|
|
76
|
+
func(...args);
|
|
77
|
+
inThrottle = true;
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
inThrottle = false;
|
|
80
|
+
}, limit);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sleep for specified milliseconds
|
|
87
|
+
*/
|
|
88
|
+
export function sleep(ms: number): Promise<void> {
|
|
89
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Retry function with exponential backoff
|
|
94
|
+
*/
|
|
95
|
+
export async function retry<T>(
|
|
96
|
+
func: () => Promise<T>,
|
|
97
|
+
options: {
|
|
98
|
+
maxRetries?: number;
|
|
99
|
+
baseDelay?: number;
|
|
100
|
+
maxDelay?: number;
|
|
101
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
102
|
+
} = {}
|
|
103
|
+
): Promise<T> {
|
|
104
|
+
const {
|
|
105
|
+
maxRetries = 3,
|
|
106
|
+
baseDelay = 1000,
|
|
107
|
+
maxDelay = 10000,
|
|
108
|
+
shouldRetry = () => true,
|
|
109
|
+
} = options;
|
|
110
|
+
|
|
111
|
+
let lastError: unknown;
|
|
112
|
+
|
|
113
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
return await func();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
lastError = error;
|
|
118
|
+
|
|
119
|
+
if (attempt === maxRetries || !shouldRetry(error)) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
|
|
124
|
+
await sleep(delay);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw lastError;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* No-op function
|
|
133
|
+
*/
|
|
134
|
+
export function noop(): void {
|
|
135
|
+
// Intentionally empty
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Identity function
|
|
140
|
+
*/
|
|
141
|
+
export function identity<T>(value: T): T {
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Constant function (returns same value regardless of input)
|
|
147
|
+
*/
|
|
148
|
+
export function constant<T>(value: T): () => T {
|
|
149
|
+
return () => value;
|
|
150
|
+
}
|
|
@@ -2,12 +2,63 @@
|
|
|
2
2
|
* Utils Index
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
export {
|
|
6
|
+
filterByProperty,
|
|
7
|
+
filterByPredicate,
|
|
8
|
+
filterByTimeRange,
|
|
9
|
+
filterByAnyProperty,
|
|
10
|
+
sortByDateDescending,
|
|
11
|
+
sortByDateAscending,
|
|
12
|
+
sortByNumberDescending,
|
|
13
|
+
sortByNumberAscending,
|
|
14
|
+
sumByProperty,
|
|
15
|
+
groupByProperty,
|
|
16
|
+
chunkArray,
|
|
17
|
+
distinctByProperty,
|
|
18
|
+
} from "./collection-filters.util";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
safeJsonParse,
|
|
22
|
+
safeJsonParseOrNull,
|
|
23
|
+
safeJsonStringify,
|
|
24
|
+
isValidJson,
|
|
25
|
+
validateObjectStructure,
|
|
26
|
+
validateObjectArray,
|
|
27
|
+
parseNumber,
|
|
28
|
+
parseBoolean,
|
|
29
|
+
clampNumber,
|
|
30
|
+
roundToDecimals,
|
|
31
|
+
deepClone,
|
|
32
|
+
mergeObjects,
|
|
33
|
+
pickProperties,
|
|
34
|
+
omitProperties,
|
|
35
|
+
} from "./data-parsers.util";
|
|
36
|
+
|
|
5
37
|
export { categorizeFalError } from "./error-categorizer";
|
|
6
38
|
export {
|
|
7
39
|
mapFalError,
|
|
40
|
+
parseFalError,
|
|
8
41
|
isFalErrorRetryable,
|
|
9
42
|
} from "./error-mapper";
|
|
10
43
|
|
|
44
|
+
export {
|
|
45
|
+
formatNumber,
|
|
46
|
+
formatCurrency,
|
|
47
|
+
formatBytes,
|
|
48
|
+
formatDuration,
|
|
49
|
+
formatPercentage,
|
|
50
|
+
formatDate,
|
|
51
|
+
formatDateTime,
|
|
52
|
+
formatRelativeTime,
|
|
53
|
+
truncateText,
|
|
54
|
+
capitalize,
|
|
55
|
+
toTitleCase,
|
|
56
|
+
toSlug,
|
|
57
|
+
formatList,
|
|
58
|
+
pluralize,
|
|
59
|
+
formatCount,
|
|
60
|
+
} from "./formatting.util";
|
|
61
|
+
|
|
11
62
|
export {
|
|
12
63
|
buildSingleImageInput,
|
|
13
64
|
buildDualImageInput,
|
|
@@ -43,10 +94,17 @@ export {
|
|
|
43
94
|
} from "./prompt-helpers.util";
|
|
44
95
|
|
|
45
96
|
export {
|
|
46
|
-
formatCreditCost,
|
|
47
97
|
buildErrorMessage,
|
|
48
98
|
isDefined,
|
|
49
99
|
removeNullish,
|
|
100
|
+
generateUniqueId,
|
|
101
|
+
debounce,
|
|
102
|
+
throttle,
|
|
103
|
+
sleep,
|
|
104
|
+
retry,
|
|
105
|
+
noop,
|
|
106
|
+
identity,
|
|
107
|
+
constant,
|
|
50
108
|
} from "./general-helpers.util";
|
|
51
109
|
|
|
52
110
|
export {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import type { FalJobMetadata } from "./job-metadata.types";
|
|
7
7
|
import { isJobStale, isJobRunning, isJobCompleted } from "./job-metadata-lifecycle.util";
|
|
8
|
+
import { sortByDateDescending, filterByPredicate } from "../collection-filters.util";
|
|
9
|
+
import { safeJsonParseOrNull, validateObjectStructure } from "../data-parsers.util";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Serialize job metadata for storage
|
|
@@ -17,55 +19,39 @@ export function serializeJobMetadata(metadata: FalJobMetadata): string {
|
|
|
17
19
|
* Deserialize job metadata from storage
|
|
18
20
|
*/
|
|
19
21
|
export function deserializeJobMetadata(data: string): FalJobMetadata | null {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
24
|
-
// eslint-disable-next-line no-console
|
|
25
|
-
console.warn('Invalid job metadata: not an object', data);
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
if (!parsed.requestId || !parsed.model || !parsed.status) {
|
|
29
|
-
// eslint-disable-next-line no-console
|
|
30
|
-
console.warn('Invalid job metadata: missing required fields', data);
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
return parsed as unknown as FalJobMetadata;
|
|
34
|
-
} catch (error) {
|
|
35
|
-
// eslint-disable-next-line no-console
|
|
36
|
-
console.error('Failed to deserialize job metadata:', error, 'Data:', data);
|
|
22
|
+
const parsed = safeJsonParseOrNull<Record<string, unknown>>(data);
|
|
23
|
+
|
|
24
|
+
if (!parsed || !validateObjectStructure<Partial<FalJobMetadata>>(parsed, ["requestId", "model", "status"] as const)) {
|
|
37
25
|
return null;
|
|
38
26
|
}
|
|
27
|
+
|
|
28
|
+
return parsed as FalJobMetadata;
|
|
39
29
|
}
|
|
40
30
|
|
|
41
31
|
/**
|
|
42
32
|
* Filter valid job metadata from array
|
|
43
33
|
*/
|
|
44
34
|
export function filterValidJobs(jobs: FalJobMetadata[]): FalJobMetadata[] {
|
|
45
|
-
return jobs
|
|
35
|
+
return filterByPredicate(jobs, (job) => !isJobStale(job));
|
|
46
36
|
}
|
|
47
37
|
|
|
48
38
|
/**
|
|
49
39
|
* Sort jobs by creation time (newest first)
|
|
50
40
|
*/
|
|
51
41
|
export function sortJobsByCreation(jobs: FalJobMetadata[]): FalJobMetadata[] {
|
|
52
|
-
return
|
|
53
|
-
const timeA = new Date(a.createdAt).getTime();
|
|
54
|
-
const timeB = new Date(b.createdAt).getTime();
|
|
55
|
-
return timeB - timeA;
|
|
56
|
-
});
|
|
42
|
+
return sortByDateDescending(jobs, "createdAt");
|
|
57
43
|
}
|
|
58
44
|
|
|
59
45
|
/**
|
|
60
46
|
* Get active jobs (not completed and not stale)
|
|
61
47
|
*/
|
|
62
48
|
export function getActiveJobs(jobs: FalJobMetadata[]): FalJobMetadata[] {
|
|
63
|
-
return jobs
|
|
49
|
+
return filterByPredicate(jobs, (job) => isJobRunning(job) && !isJobStale(job));
|
|
64
50
|
}
|
|
65
51
|
|
|
66
52
|
/**
|
|
67
53
|
* Get completed jobs
|
|
68
54
|
*/
|
|
69
55
|
export function getCompletedJobs(jobs: FalJobMetadata[]): FalJobMetadata[] {
|
|
70
|
-
return jobs
|
|
56
|
+
return filterByPredicate(jobs, isJobCompleted);
|
|
71
57
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { FalJobMetadata } from "../job-metadata";
|
|
7
7
|
import { updateJobMetadata } from "../job-metadata";
|
|
8
8
|
import type { IJobStorage } from "./job-storage-interface";
|
|
9
|
+
import { safeJsonParseOrNull, safeJsonStringify } from "../data-parsers.util";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Save job metadata to storage
|
|
@@ -15,7 +16,7 @@ export async function saveJobMetadata(
|
|
|
15
16
|
metadata: FalJobMetadata
|
|
16
17
|
): Promise<void> {
|
|
17
18
|
const key = `fal_job:${metadata.requestId}`;
|
|
18
|
-
const value =
|
|
19
|
+
const value = safeJsonStringify(metadata, "{}");
|
|
19
20
|
await storage.setItem(key, value);
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -30,11 +31,7 @@ export async function loadJobMetadata(
|
|
|
30
31
|
const value = await storage.getItem(key);
|
|
31
32
|
if (!value) return null;
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
return JSON.parse(value) as FalJobMetadata;
|
|
35
|
-
} catch {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
34
|
+
return safeJsonParseOrNull<FalJobMetadata>(value);
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
/**
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { FalJobMetadata } from "../job-metadata";
|
|
7
7
|
import type { IJobStorage } from "./job-storage-interface";
|
|
8
8
|
import { deleteJobMetadata } from "./job-storage-crud.util";
|
|
9
|
+
import { safeJsonParseOrNull } from "../data-parsers.util";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Load all jobs from storage
|
|
@@ -24,12 +25,9 @@ export async function loadAllJobs(
|
|
|
24
25
|
for (const key of jobKeys) {
|
|
25
26
|
const value = await storage.getItem(key);
|
|
26
27
|
if (value) {
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
const metadata = safeJsonParseOrNull<FalJobMetadata>(value);
|
|
29
|
+
if (metadata) {
|
|
29
30
|
jobs.push(metadata);
|
|
30
|
-
} catch {
|
|
31
|
-
// Skip invalid entries
|
|
32
|
-
continue;
|
|
33
31
|
}
|
|
34
32
|
}
|
|
35
33
|
}
|