@umituz/react-native-ai-fal-provider 2.0.21 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-fal-provider",
3
- "version": "2.0.21",
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",
@@ -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
- formatCreditCost,
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
- getRequestStore().set(key, request);
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
- * Note: This is a placeholder for future implementation.
67
- * Currently, requests are cleaned up automatically when they complete.
71
+ * @param maxAge - Maximum age in milliseconds (default: 5 minutes)
72
+ * @returns Number of requests cleaned up
68
73
  */
69
- export function cleanupRequestStore(_maxAge: number = 300000): void {
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
- // Requests are automatically removed when they complete (via finally block)
73
- // This function exists for future enhancements like time-based cleanup
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
- // Store size exceeds threshold - indicates potential memory leak
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
  }
@@ -146,7 +146,7 @@ export function groupByProperty<T>(
146
146
  export function chunkArray<T>(items: readonly T[], chunkSize: number): T[][] {
147
147
  const result: T[][] = [];
148
148
  for (let i = 0; i < items.length; i += chunkSize) {
149
- result.push(items.slice(i, i + chunkSize) as T[]);
149
+ result.push([...items.slice(i, i + chunkSize)]);
150
150
  }
151
151
  return result;
152
152
  }
@@ -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 filterCostsByModel(this.costHistory, modelId);
153
+ return filterByProperty(this.costHistory, "model", modelId);
165
154
  }
166
155
 
167
156
  getCostsByOperation(operation: string): GenerationCost[] {
168
- return filterCostsByOperation(this.costHistory, operation);
157
+ return filterByProperty(this.costHistory, "operation", operation);
169
158
  }
170
159
 
171
160
  getCostsByTimeRange(startTime: number, endTime: number): GenerationCost[] {
172
- return filterCostsByTimeRange(this.costHistory, startTime, endTime);
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
- try {
21
- const parsed = JSON.parse(data) as Record<string, unknown>;
22
- // Validate structure
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.filter((job) => !isJobStale(job));
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 [...jobs].sort((a, b) => {
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.filter((job) => isJobRunning(job) && !isJobStale(job));
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.filter((job) => isJobCompleted(job));
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 = JSON.stringify(metadata);
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
- try {
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
- try {
28
- const metadata = JSON.parse(value) as FalJobMetadata;
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
  }