@umituz/react-native-ai-fal-provider 3.2.30 → 3.2.32

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": "3.2.30",
3
+ "version": "3.2.32",
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",
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * FAL Provider Subscription Handlers
3
- * Handles subscribe, run methods and cancellation logic
3
+ * Handles subscribe, run methods with retry, timeout, and cancellation
4
+ *
5
+ * Retry strategy for subscribe:
6
+ * - Retries on: network errors, timeouts, server errors (5xx)
7
+ * - NO retry on: auth, validation, NSFW, quota, user cancellation
8
+ * - Max 1 retry (2 total attempts) with 3s delay
9
+ * - Upload results are preserved (images already on CDN)
4
10
  */
5
11
 
6
12
  import { fal, ApiError, ValidationError } from "@fal-ai/client";
@@ -10,12 +16,12 @@ import { mapFalStatusToJobStatus } from "./fal-status-mapper";
10
16
  import { validateNSFWContent } from "../validators/nsfw-validator";
11
17
  import { NSFWContentError } from "./nsfw-content-error";
12
18
  import { isBase64DataUri } from "../utils/validators/data-uri-validator.util";
19
+ import { generationLogCollector } from "../utils/log-collector";
20
+
21
+ const TAG = 'fal-subscription';
22
+
23
+ // ─── Helpers ────────────────────────────────────────────────────────────────
13
24
 
14
- /**
15
- * Validate that FAL response images contain HTTPS URLs, never base64 data URIs.
16
- * FAL models should always return CDN URLs. If base64 is returned, it means the model
17
- * was called with sync_mode:true or wrong parameters. Throw an explicit error to catch early.
18
- */
19
25
  function validateNoBase64InResponse(data: unknown): void {
20
26
  if (!data || typeof data !== "object") return;
21
27
  const record = data as Record<string, unknown>;
@@ -41,10 +47,6 @@ function validateNoBase64InResponse(data: unknown): void {
41
47
  }
42
48
  }
43
49
 
44
- /**
45
- * Unwrap fal.subscribe / fal.run Result<T> = { data: T, requestId: string }
46
- * Throws if response format is unexpected - no silent fallbacks
47
- */
48
50
  function unwrapFalResult<T>(rawResult: unknown): { data: T; requestId: string } {
49
51
  if (!rawResult || typeof rawResult !== "object") {
50
52
  throw new Error(
@@ -69,10 +71,6 @@ function unwrapFalResult<T>(rawResult: unknown): { data: T; requestId: string }
69
71
  return { data: result.data as T, requestId: result.requestId };
70
72
  }
71
73
 
72
- /**
73
- * Format fal-ai SDK errors into user-readable messages
74
- * Uses proper @fal-ai/client error types (ApiError, ValidationError)
75
- */
76
74
  function formatFalError(error: unknown): string {
77
75
  if (error instanceof ValidationError) {
78
76
  const details = error.fieldErrors;
@@ -83,7 +81,6 @@ function formatFalError(error: unknown): string {
83
81
  }
84
82
 
85
83
  if (error instanceof ApiError) {
86
- // ApiError has .status, .body, .message
87
84
  if (error.status === 401 || error.status === 403) {
88
85
  return "Authentication failed. Please check your API key.";
89
86
  }
@@ -103,23 +100,57 @@ function formatFalError(error: unknown): string {
103
100
  return String(error);
104
101
  }
105
102
 
103
+ // ─── Retry Logic ────────────────────────────────────────────────────────────
104
+
106
105
  /**
107
- * Handle FAL subscription with timeout and cancellation
106
+ * Determine if a subscribe error is retryable.
107
+ *
108
+ * Retryable: network, timeout, server errors (500-504)
109
+ * NOT: auth (401/403), validation (400/422), quota (402),
110
+ * NSFW, user cancellation, rate limit (429 — FAL SDK already retries)
108
111
  */
109
- export async function handleFalSubscription<T = unknown>(
110
- model: string,
111
- input: Record<string, unknown>,
112
- options?: SubscribeOptions<T>,
113
- signal?: AbortSignal
114
- ): Promise<{ result: T; requestId: string }> {
115
- const timeoutMs = options?.timeoutMs ?? DEFAULT_FAL_CONFIG.defaultTimeoutMs;
112
+ function isRetryableSubscribeError(error: unknown): boolean {
113
+ // Never retry NSFW
114
+ if (error instanceof NSFWContentError) return false;
116
115
 
117
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 3600000) {
118
- throw new Error(
119
- `Invalid timeout: ${timeoutMs}ms. Must be a positive integer between 1 and 3600000ms (1 hour)`
120
- );
116
+ // Never retry user cancellation
117
+ if (error instanceof Error && error.message.includes("cancelled by user")) return false;
118
+
119
+ // ApiError — check status code
120
+ if (error instanceof ApiError) {
121
+ const status = error.status;
122
+ // 5xx server errors are retryable
123
+ if (status >= 500 && status <= 504) return true;
124
+ // Everything else (4xx) is not
125
+ return false;
121
126
  }
122
127
 
128
+ // ValidationError is never retryable
129
+ if (error instanceof ValidationError) return false;
130
+
131
+ // Generic Error — check message patterns
132
+ if (error instanceof Error) {
133
+ const msg = error.message.toLowerCase();
134
+ if (msg.includes("network")) return true;
135
+ if (msg.includes("timeout") || msg.includes("timed out")) return true;
136
+ if (msg.includes("fetch")) return true;
137
+ if (msg.includes("econnrefused") || msg.includes("enotfound")) return true;
138
+ }
139
+
140
+ return false;
141
+ }
142
+
143
+ // ─── Single Subscribe Attempt ───────────────────────────────────────────────
144
+
145
+ async function singleSubscribeAttempt<T = unknown>(
146
+ model: string,
147
+ input: Record<string, unknown>,
148
+ options: SubscribeOptions<T> | undefined,
149
+ signal: AbortSignal | undefined,
150
+ timeoutMs: number,
151
+ attemptStart: number,
152
+ sessionId: string,
153
+ ): Promise<{ result: T; requestId: string }> {
123
154
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
124
155
  let abortHandler: (() => void) | null = null;
125
156
  let listenerAdded = false;
@@ -146,9 +177,8 @@ export async function handleFalSubscription<T = unknown>(
146
177
  const statusWithPosition = `${jobStatus.status}:${jobStatus.queuePosition ?? ""}`;
147
178
  if (statusWithPosition !== lastStatus) {
148
179
  lastStatus = statusWithPosition;
149
- if (typeof __DEV__ !== "undefined" && __DEV__) {
150
- console.log(`[fal-provider] Job Status Update: ${jobStatus.status}${jobStatus.queuePosition ? ` (Position: ${jobStatus.queuePosition})` : ""}`);
151
- }
180
+ const elapsed = Date.now() - attemptStart;
181
+ generationLogCollector.log(sessionId, TAG, `[${elapsed}ms] Queue: ${jobStatus.status}${jobStatus.queuePosition ? ` (pos: ${jobStatus.queuePosition})` : ""}`);
152
182
  if (options?.onProgress) {
153
183
  if (jobStatus.status === "IN_QUEUE" || jobStatus.status === "IN_PROGRESS") {
154
184
  options.onProgress({ progress: -1, status: jobStatus.status });
@@ -176,7 +206,6 @@ export async function handleFalSubscription<T = unknown>(
176
206
  listenerAdded = true;
177
207
  });
178
208
  promises.push(abortPromise);
179
- // Check after listener is attached to close the race window
180
209
  if (signal.aborted) {
181
210
  throw new Error("Request cancelled by user");
182
211
  }
@@ -185,42 +214,123 @@ export async function handleFalSubscription<T = unknown>(
185
214
  const rawResult = await Promise.race(promises);
186
215
  const { data, requestId } = unwrapFalResult<T>(rawResult);
187
216
 
188
- // Validate response for base64 data URIs
189
- // Since we use subscribe, we should always get CDN URLs, not base64 data URIs
190
217
  validateNoBase64InResponse(data);
191
218
  validateNSFWContent(data as Record<string, unknown>);
192
219
 
193
- if (typeof __DEV__ !== "undefined" && __DEV__) {
194
- console.log(`[fal-provider] Subscription completed successfully. Request ID: ${requestId}`);
195
- }
196
-
197
220
  options?.onResult?.(data);
198
221
  return { result: data, requestId };
199
- } catch (error) {
200
- if (error instanceof NSFWContentError) {
201
- throw error;
202
- }
203
-
204
- const message = formatFalError(error);
205
- throw new Error(message);
206
222
  } finally {
207
- if (timeoutId) {
208
- clearTimeout(timeoutId);
209
- }
223
+ if (timeoutId) clearTimeout(timeoutId);
210
224
  if (listenerAdded && abortHandler && signal) {
211
225
  signal.removeEventListener("abort", abortHandler);
212
226
  }
213
227
  }
214
228
  }
215
229
 
230
+ // ─── Public API ─────────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Handle FAL subscription with timeout, cancellation, and retry.
234
+ *
235
+ * Retry is safe here because:
236
+ * - Input is already preprocessed (images uploaded to FAL CDN)
237
+ * - fal.subscribe is idempotent (same input → same generation)
238
+ * - Only retries on transient errors (network/timeout/5xx)
239
+ */
240
+ export async function handleFalSubscription<T = unknown>(
241
+ model: string,
242
+ input: Record<string, unknown>,
243
+ sessionId: string,
244
+ options?: SubscribeOptions<T>,
245
+ signal?: AbortSignal,
246
+ ): Promise<{ result: T; requestId: string }> {
247
+ const overallStart = Date.now();
248
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_FAL_CONFIG.defaultTimeoutMs;
249
+ const maxRetries = DEFAULT_FAL_CONFIG.subscribeMaxRetries;
250
+ const retryDelay = DEFAULT_FAL_CONFIG.subscribeRetryDelayMs;
251
+
252
+ generationLogCollector.log(sessionId, TAG, `Starting subscription for model: ${model}`, {
253
+ timeoutMs,
254
+ maxRetries,
255
+ inputKeys: Object.keys(input),
256
+ });
257
+
258
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 3600000) {
259
+ throw new Error(
260
+ `Invalid timeout: ${timeoutMs}ms. Must be a positive integer between 1 and 3600000ms (1 hour)`
261
+ );
262
+ }
263
+
264
+ let lastError: unknown;
265
+
266
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
267
+ const attemptStart = Date.now();
268
+
269
+ if (attempt > 0) {
270
+ generationLogCollector.warn(sessionId, TAG, `Retry ${attempt}/${maxRetries} after ${retryDelay}ms delay...`);
271
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
272
+
273
+ // Check if cancelled during delay
274
+ if (signal?.aborted) {
275
+ throw new Error("Request cancelled by user");
276
+ }
277
+ }
278
+
279
+ try {
280
+ generationLogCollector.log(sessionId, TAG, `Attempt ${attempt + 1}/${maxRetries + 1} starting...`);
281
+
282
+ const result = await singleSubscribeAttempt<T>(
283
+ model, input, options, signal, timeoutMs, attemptStart, sessionId,
284
+ );
285
+
286
+ const totalElapsed = Date.now() - overallStart;
287
+ const suffix = attempt > 0 ? ` (succeeded on retry ${attempt})` : '';
288
+ generationLogCollector.log(sessionId, TAG, `Subscription completed in ${totalElapsed}ms${suffix}. Request ID: ${result.requestId}`);
289
+
290
+ return result;
291
+ } catch (error) {
292
+ lastError = error;
293
+ const attemptElapsed = Date.now() - attemptStart;
294
+
295
+ // NSFW errors never retry — re-throw immediately
296
+ if (error instanceof NSFWContentError) {
297
+ generationLogCollector.warn(sessionId, TAG, `NSFW content detected after ${attemptElapsed}ms`);
298
+ throw error;
299
+ }
300
+
301
+ const message = formatFalError(error);
302
+
303
+ // Check if retryable and we have attempts left
304
+ if (attempt < maxRetries && isRetryableSubscribeError(error)) {
305
+ generationLogCollector.warn(sessionId, TAG, `Attempt ${attempt + 1} failed after ${attemptElapsed}ms (retryable): ${message}`);
306
+ continue;
307
+ }
308
+
309
+ // Not retryable or no retries left
310
+ const totalElapsed = Date.now() - overallStart;
311
+ const retryInfo = attempt > 0 ? ` after ${attempt + 1} attempts` : '';
312
+ generationLogCollector.error(sessionId, TAG, `Subscription FAILED in ${totalElapsed}ms${retryInfo}: ${message}`);
313
+ throw new Error(message);
314
+ }
315
+ }
316
+
317
+ // Should never reach here, but TypeScript needs it
318
+ throw lastError;
319
+ }
320
+
216
321
  /**
217
- * Handle FAL run with NSFW validation
322
+ * Handle FAL run with NSFW validation (no retry — runs are fast/synchronous)
218
323
  */
219
324
  export async function handleFalRun<T = unknown>(
220
325
  model: string,
221
326
  input: Record<string, unknown>,
222
- options?: RunOptions
327
+ sessionId: string,
328
+ options?: RunOptions,
223
329
  ): Promise<T> {
330
+ const runTag = 'fal-run';
331
+ const startTime = Date.now();
332
+ generationLogCollector.log(sessionId, runTag, `Starting run for model: ${model}`);
333
+
224
334
  options?.onProgress?.({ progress: -1, status: "IN_PROGRESS" as const });
225
335
 
226
336
  try {
@@ -230,14 +340,21 @@ export async function handleFalRun<T = unknown>(
230
340
  validateNoBase64InResponse(data);
231
341
  validateNSFWContent(data as Record<string, unknown>);
232
342
 
343
+ const elapsed = Date.now() - startTime;
344
+ generationLogCollector.log(sessionId, runTag, `Run completed in ${elapsed}ms`);
345
+
233
346
  options?.onProgress?.({ progress: 100, status: "COMPLETED" as const });
234
347
  return data;
235
348
  } catch (error) {
349
+ const elapsed = Date.now() - startTime;
350
+
236
351
  if (error instanceof NSFWContentError) {
352
+ generationLogCollector.warn(sessionId, runTag, `NSFW content detected after ${elapsed}ms`);
237
353
  throw error;
238
354
  }
239
355
 
240
356
  const message = formatFalError(error);
357
+ generationLogCollector.error(sessionId, runTag, `Run FAILED after ${elapsed}ms for model ${model}: ${message}`);
241
358
  throw new Error(message);
242
359
  }
243
360
  }
@@ -1,16 +1,52 @@
1
1
  /**
2
2
  * FAL Provider Constants
3
3
  * Configuration and capability definitions for FAL AI provider
4
+ *
5
+ * Retry Strategy (layered):
6
+ * ┌──────────────────────────────────────────────────┐
7
+ * │ UPLOAD (fal.storage.upload) — per image │
8
+ * │ Timeout: 30s / attempt │
9
+ * │ Retries: 2 (3 total attempts) │
10
+ * │ Backoff: 1s → 2s (exponential) │
11
+ * │ Retries on: network, timeout │
12
+ * ├──────────────────────────────────────────────────┤
13
+ * │ SUBSCRIBE (fal.subscribe) — generation │
14
+ * │ Timeout: caller-defined (120s image / 300s video)│
15
+ * │ Retries: 1 (2 total attempts) │
16
+ * │ Backoff: 3s (fixed — server needs recovery time) │
17
+ * │ Retries on: network, timeout, server (5xx) │
18
+ * │ NO retry: auth, validation, NSFW, quota, cancel │
19
+ * ├──────────────────────────────────────────────────┤
20
+ * │ FAL SDK HTTP retry (fal.config) │
21
+ * │ Retries: 3 (internal HTTP-level only) │
22
+ * │ Backoff: 1s → 10s │
23
+ * └──────────────────────────────────────────────────┘
4
24
  */
5
25
 
6
26
  import type { ProviderCapabilities } from "../../domain/types";
7
27
 
8
28
  export const DEFAULT_FAL_CONFIG = {
29
+ /** FAL SDK HTTP-level retry */
9
30
  maxRetries: 3,
10
31
  baseDelay: 1000,
11
32
  maxDelay: 10000,
33
+
34
+ /** Subscribe defaults */
12
35
  defaultTimeoutMs: 300000,
13
36
  pollInterval: 2500,
37
+
38
+ /** Subscribe retry — retries the entire fal.subscribe call on transient failures */
39
+ subscribeMaxRetries: 1,
40
+ subscribeRetryDelayMs: 3000,
41
+ } as const;
42
+
43
+ export const UPLOAD_CONFIG = {
44
+ /** Timeout per individual upload attempt */
45
+ timeoutMs: 30_000,
46
+ /** Max retries (2 = 3 total attempts) */
47
+ maxRetries: 2,
48
+ /** Initial backoff delay (doubles each retry) */
49
+ baseDelayMs: 1_000,
14
50
  } as const;
15
51
 
16
52
  export const FAL_CAPABILITIES: ProviderCapabilities = {
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * FAL Provider - Implements IAIProvider interface
3
+ * Each subscribe/run call creates an isolated log session via sessionId.
3
4
  */
4
5
 
5
6
  import { fal } from "@fal-ai/client";
@@ -11,6 +12,8 @@ import type {
11
12
  import { DEFAULT_FAL_CONFIG, FAL_CAPABILITIES } from "./fal-provider.constants";
12
13
  import { handleFalSubscription, handleFalRun } from "./fal-provider-subscription";
13
14
  import { preprocessInput } from "../utils";
15
+ import { generationLogCollector } from "../utils/log-collector";
16
+ import type { LogEntry } from "../utils/log-collector";
14
17
  import {
15
18
  createRequestKey, getExistingRequest, storeRequest,
16
19
  removeRequest, cancelRequest, cancelAllRequests, hasActiveRequests,
@@ -25,6 +28,8 @@ export class FalProvider implements IAIProvider {
25
28
  private apiKey: string | null = null;
26
29
  private initialized = false;
27
30
  private lastRequestKey: string | null = null;
31
+ /** Tracks the active sessionId so callers can retrieve logs */
32
+ private activeSessionId: string | null = null;
28
33
 
29
34
  initialize(config: AIProviderConfig): void {
30
35
  this.apiKey = config.apiKey;
@@ -74,7 +79,10 @@ export class FalProvider implements IAIProvider {
74
79
  async submitJob(model: string, input: Record<string, unknown>): Promise<JobSubmission> {
75
80
  this.validateInit();
76
81
  validateInput(model, input);
77
- const processedInput = await preprocessInput(input);
82
+ const sessionId = generationLogCollector.startSession();
83
+ this.activeSessionId = sessionId;
84
+ generationLogCollector.log(sessionId, 'fal-provider', `submitJob() for model: ${model}`);
85
+ const processedInput = await preprocessInput(input, sessionId);
78
86
  return queueOps.submitJob(model, processedInput);
79
87
  }
80
88
 
@@ -93,14 +101,28 @@ export class FalProvider implements IAIProvider {
93
101
  input: Record<string, unknown>,
94
102
  options?: SubscribeOptions<T>,
95
103
  ): Promise<T> {
104
+ const TAG = 'fal-provider';
105
+ const totalStart = Date.now();
96
106
  this.validateInit();
97
107
  validateInput(model, input);
98
108
 
99
- const processedInput = await preprocessInput(input);
109
+ // Start a fresh log session for this generation
110
+ const sessionId = generationLogCollector.startSession();
111
+ this.activeSessionId = sessionId;
112
+ generationLogCollector.log(sessionId, TAG, `subscribe() called for model: ${model}`);
113
+
114
+ const preprocessStart = Date.now();
115
+ const processedInput = await preprocessInput(input, sessionId);
116
+ const preprocessElapsed = Date.now() - preprocessStart;
117
+ generationLogCollector.log(sessionId, TAG, `Preprocessing done in ${preprocessElapsed}ms`);
118
+
100
119
  const key = createRequestKey(model, processedInput);
101
120
 
102
121
  const existing = getExistingRequest<T>(key);
103
- if (existing) return existing.promise;
122
+ if (existing) {
123
+ generationLogCollector.log(sessionId, TAG, `Dedup hit - returning existing request`);
124
+ return existing.promise;
125
+ }
104
126
 
105
127
  const abortController = new AbortController();
106
128
 
@@ -114,19 +136,22 @@ export class FalProvider implements IAIProvider {
114
136
  this.lastRequestKey = key;
115
137
  storeRequest(key, { promise, abortController, createdAt: Date.now() });
116
138
 
117
- handleFalSubscription<T>(model, processedInput, options, abortController.signal)
118
- .then((res) => resolvePromise(res.result))
119
- .catch((error) => rejectPromise(error))
139
+ handleFalSubscription<T>(model, processedInput, sessionId, options, abortController.signal)
140
+ .then((res) => {
141
+ const totalElapsed = Date.now() - totalStart;
142
+ generationLogCollector.log(sessionId, TAG, `Generation SUCCESS in ${totalElapsed}ms (preprocess: ${preprocessElapsed}ms)`);
143
+ resolvePromise(res.result);
144
+ })
145
+ .catch((error) => {
146
+ const totalElapsed = Date.now() - totalStart;
147
+ generationLogCollector.error(sessionId, TAG, `Generation FAILED in ${totalElapsed}ms: ${error instanceof Error ? error.message : String(error)}`);
148
+ rejectPromise(error);
149
+ })
120
150
  .finally(() => {
121
151
  try {
122
152
  removeRequest(key);
123
153
  } catch (cleanupError) {
124
- if (typeof __DEV__ !== "undefined" && __DEV__) {
125
- console.error(
126
- `[fal-provider] Error removing request: ${key}`,
127
- cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
128
- );
129
- }
154
+ generationLogCollector.warn(sessionId, TAG, `Error removing request: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
130
155
  }
131
156
  });
132
157
 
@@ -136,25 +161,30 @@ export class FalProvider implements IAIProvider {
136
161
  async run<T = unknown>(model: string, input: Record<string, unknown>, options?: RunOptions): Promise<T> {
137
162
  this.validateInit();
138
163
  validateInput(model, input);
139
- const processedInput = await preprocessInput(input);
164
+
165
+ const sessionId = generationLogCollector.startSession();
166
+ this.activeSessionId = sessionId;
167
+ generationLogCollector.log(sessionId, 'fal-provider', `run() for model: ${model}`);
168
+
169
+ const processedInput = await preprocessInput(input, sessionId);
140
170
 
141
171
  const signal = options?.signal;
142
172
  if (signal?.aborted) {
143
173
  throw new Error("Request cancelled by user");
144
174
  }
145
175
 
146
- return handleFalRun<T>(model, processedInput, options);
176
+ return handleFalRun<T>(model, processedInput, sessionId, options);
147
177
  }
148
178
 
149
179
  reset(): void {
150
180
  cancelAllRequests();
151
181
  this.lastRequestKey = null;
182
+ this.activeSessionId = null;
152
183
  this.apiKey = null;
153
184
  this.initialized = false;
154
185
  }
155
186
 
156
187
  cancelCurrentRequest(): void {
157
- // Cancel only this provider instance's last request, not all global requests
158
188
  if (this.lastRequestKey) {
159
189
  cancelRequest(this.lastRequestKey);
160
190
  this.lastRequestKey = null;
@@ -164,6 +194,25 @@ export class FalProvider implements IAIProvider {
164
194
  hasRunningRequest(): boolean {
165
195
  return hasActiveRequests();
166
196
  }
197
+
198
+ /**
199
+ * Get all log entries collected during the current/last generation session.
200
+ * Returns empty array if no active session.
201
+ */
202
+ getSessionLogs(): LogEntry[] {
203
+ if (!this.activeSessionId) return [];
204
+ return generationLogCollector.getEntries(this.activeSessionId);
205
+ }
206
+
207
+ /**
208
+ * End the current log session and return all entries. Clears the buffer.
209
+ */
210
+ endLogSession(): LogEntry[] {
211
+ if (!this.activeSessionId) return [];
212
+ const entries = generationLogCollector.endSession(this.activeSessionId);
213
+ this.activeSessionId = null;
214
+ return entries;
215
+ }
167
216
  }
168
217
 
169
218
  export const falProvider = new FalProvider();
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * FAL Storage Utility
3
3
  * Handles image uploads to FAL storage (React Native compatible)
4
+ * Features: timeout protection, retry with exponential backoff, session-scoped logging
4
5
  */
5
6
 
6
7
  import { fal } from "@fal-ai/client";
@@ -9,15 +10,71 @@ import {
9
10
  deleteTempFile,
10
11
  } from "@umituz/react-native-design-system/filesystem";
11
12
  import { getErrorMessage } from './helpers/error-helpers.util';
13
+ import { generationLogCollector } from './log-collector';
14
+ import { UPLOAD_CONFIG } from '../services/fal-provider.constants';
15
+
16
+ const TAG = 'fal-storage';
17
+
18
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
19
+ let timeoutId: ReturnType<typeof setTimeout>;
20
+ const timeoutPromise = new Promise<never>((_, reject) => {
21
+ timeoutId = setTimeout(() => {
22
+ reject(new Error(`Upload timeout after ${ms}ms: ${label}`));
23
+ }, ms);
24
+ });
25
+
26
+ return Promise.race([promise, timeoutPromise]).finally(() => {
27
+ clearTimeout(timeoutId);
28
+ });
29
+ }
30
+
31
+ async function withRetry<T>(
32
+ fn: () => Promise<T>,
33
+ sessionId: string,
34
+ label: string,
35
+ maxRetries: number = UPLOAD_CONFIG.maxRetries,
36
+ baseDelay: number = UPLOAD_CONFIG.baseDelayMs,
37
+ ): Promise<T> {
38
+ let lastError: unknown;
39
+
40
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
41
+ try {
42
+ if (attempt > 0) {
43
+ const delay = baseDelay * Math.pow(2, attempt - 1);
44
+ generationLogCollector.warn(sessionId, TAG, `Retry ${attempt}/${maxRetries} for ${label} after ${delay}ms`);
45
+ await new Promise(resolve => setTimeout(resolve, delay));
46
+ }
47
+ return await fn();
48
+ } catch (error) {
49
+ lastError = error;
50
+ const errorMsg = getErrorMessage(error);
51
+ const isTransient =
52
+ errorMsg.toLowerCase().includes('network') ||
53
+ errorMsg.includes('timeout') ||
54
+ errorMsg.includes('timed out') ||
55
+ errorMsg.includes('ECONNREFUSED') ||
56
+ errorMsg.includes('ENOTFOUND') ||
57
+ errorMsg.includes('fetch');
58
+
59
+ if (attempt < maxRetries && isTransient) {
60
+ generationLogCollector.warn(sessionId, TAG, `Attempt ${attempt + 1} failed for ${label}: ${errorMsg}`);
61
+ continue;
62
+ }
63
+ break;
64
+ }
65
+ }
66
+
67
+ throw lastError;
68
+ }
12
69
 
13
70
  /**
14
71
  * Upload base64 image to FAL storage
15
- * Uses design system's filesystem utilities for React Native compatibility
16
72
  */
17
- export async function uploadToFalStorage(base64: string): Promise<string> {
18
- if (typeof __DEV__ !== "undefined" && __DEV__) {
19
- console.log(`[fal-storage] Uploading base64 image to FAL (first 50 chars): ${base64.substring(0, 50)}...`);
20
- }
73
+ export async function uploadToFalStorage(base64: string, sessionId: string): Promise<string> {
74
+ const startTime = Date.now();
75
+ const sizeKB = Math.round(base64.length / 1024);
76
+ const actualSizeKB = Math.round(sizeKB * 0.75); // base64 inflates ~33%
77
+ generationLogCollector.log(sessionId, TAG, `Starting upload (~${actualSizeKB}KB actual)`);
21
78
 
22
79
  const tempUri = await base64ToTempFile(base64);
23
80
 
@@ -26,66 +83,86 @@ export async function uploadToFalStorage(base64: string): Promise<string> {
26
83
  }
27
84
 
28
85
  try {
29
- const response = await fetch(tempUri);
30
- const blob = await response.blob();
31
- const url = await fal.storage.upload(blob);
32
-
33
- if (typeof __DEV__ !== "undefined" && __DEV__) {
34
- console.log(`[fal-storage] Successfully uploaded base64 data to FAL. URL: ${url}`);
35
- }
86
+ const url = await withRetry(
87
+ async () => {
88
+ const response = await fetch(tempUri);
89
+ const blob = await response.blob();
90
+ generationLogCollector.log(sessionId, TAG, `Blob created (${blob.size} bytes), uploading to FAL CDN...`);
91
+ return withTimeout(
92
+ fal.storage.upload(blob),
93
+ UPLOAD_CONFIG.timeoutMs,
94
+ `image (~${actualSizeKB}KB)`,
95
+ );
96
+ },
97
+ sessionId,
98
+ 'upload',
99
+ );
36
100
 
101
+ const elapsed = Date.now() - startTime;
102
+ generationLogCollector.log(sessionId, TAG, `Upload complete in ${elapsed}ms`, { url, actualSizeKB, elapsed });
37
103
  return url;
104
+ } catch (error) {
105
+ const elapsed = Date.now() - startTime;
106
+ generationLogCollector.error(sessionId, TAG, `Upload FAILED after ${elapsed}ms: ${getErrorMessage(error)}`, { actualSizeKB, elapsed });
107
+ throw error;
38
108
  } finally {
39
109
  try {
40
110
  await deleteTempFile(tempUri);
41
111
  } catch (cleanupError) {
42
- // Log cleanup errors to prevent disk space leaks
43
- console.warn(
44
- `[fal-storage] Failed to delete temp file: ${tempUri}`,
45
- getErrorMessage(cleanupError)
46
- );
47
- // Don't throw - cleanup errors shouldn't fail the upload
112
+ generationLogCollector.warn(sessionId, TAG, `Failed to delete temp file: ${getErrorMessage(cleanupError)}`);
48
113
  }
49
114
  }
50
115
  }
51
116
 
52
117
  /**
53
- * Upload a local file (file:// or content:// URI) to FAL storage
54
- * Directly fetches the file as a blob — no base64 intermediate step
118
+ * Upload a local file to FAL storage
55
119
  */
56
- export async function uploadLocalFileToFalStorage(fileUri: string): Promise<string> {
57
- try {
58
- if (typeof __DEV__ !== "undefined" && __DEV__) {
59
- console.log(`[fal-storage] Starting local file upload to FAL: ${fileUri}`);
60
- }
120
+ export async function uploadLocalFileToFalStorage(fileUri: string, sessionId: string): Promise<string> {
121
+ const startTime = Date.now();
122
+ generationLogCollector.log(sessionId, TAG, `Starting local file upload: ${fileUri}`);
61
123
 
62
- const response = await fetch(fileUri);
63
- const blob = await response.blob();
64
- const url = await fal.storage.upload(blob);
65
-
66
- if (typeof __DEV__ !== "undefined" && __DEV__) {
67
- console.log(`[fal-storage] Successfully uploaded local file to FAL. URL: ${url}`);
68
- }
124
+ try {
125
+ const url = await withRetry(
126
+ async () => {
127
+ const response = await fetch(fileUri);
128
+ const blob = await response.blob();
129
+ generationLogCollector.log(sessionId, TAG, `Local file blob (${blob.size} bytes), uploading...`);
130
+ return withTimeout(
131
+ fal.storage.upload(blob),
132
+ UPLOAD_CONFIG.timeoutMs,
133
+ `local file`,
134
+ );
135
+ },
136
+ sessionId,
137
+ 'local file upload',
138
+ );
69
139
 
140
+ const elapsed = Date.now() - startTime;
141
+ generationLogCollector.log(sessionId, TAG, `Local file upload complete in ${elapsed}ms`, { url, elapsed });
70
142
  return url;
71
143
  } catch (error) {
72
- throw new Error(
73
- `Failed to upload local file to FAL storage: ${getErrorMessage(error)}`
74
- );
144
+ const elapsed = Date.now() - startTime;
145
+ generationLogCollector.error(sessionId, TAG, `Local file upload FAILED after ${elapsed}ms: ${getErrorMessage(error)}`, { elapsed });
146
+ throw error;
75
147
  }
76
148
  }
77
149
 
78
150
  /**
79
151
  * Upload multiple images to FAL storage in parallel
80
- * Uses Promise.allSettled to handle partial failures gracefully
81
- * @throws {Error} if any upload fails, with details about all failures
82
- * Note: Successful uploads before the first failure are NOT cleaned up automatically
83
- * as FAL storage doesn't provide a delete API. Monitor orphaned uploads externally.
84
152
  */
85
153
  export async function uploadMultipleToFalStorage(
86
154
  images: string[],
155
+ sessionId: string,
87
156
  ): Promise<string[]> {
88
- const results = await Promise.allSettled(images.map(uploadToFalStorage));
157
+ const startTime = Date.now();
158
+ generationLogCollector.log(sessionId, TAG, `Starting batch upload of ${images.length} image(s)`);
159
+
160
+ const results = await Promise.allSettled(
161
+ images.map((img, i) => {
162
+ generationLogCollector.log(sessionId, TAG, `[${i}/${images.length}] Queuing upload (~${Math.round(img.length * 0.75 / 1024)}KB)`);
163
+ return uploadToFalStorage(img, sessionId);
164
+ })
165
+ );
89
166
 
90
167
  const successfulUploads: string[] = [];
91
168
  const failures: Array<{ index: number; error: unknown }> = [];
@@ -98,27 +175,19 @@ export async function uploadMultipleToFalStorage(
98
175
  }
99
176
  });
100
177
 
101
- // If any upload failed, throw detailed error
178
+ const elapsed = Date.now() - startTime;
179
+
102
180
  if (failures.length > 0) {
103
181
  const errorMessage = failures
104
- .map(({ index, error }) =>
105
- `Image ${index}: ${getErrorMessage(error)}`
106
- )
182
+ .map(({ index, error }) => `Image ${index}: ${getErrorMessage(error)}`)
107
183
  .join('; ');
108
184
 
109
- // Log warning about orphaned uploads
110
- if (successfulUploads.length > 0) {
111
- console.warn(
112
- `[fal-storage] ${successfulUploads.length} upload(s) succeeded before failure. ` +
113
- 'These files remain in FAL storage and may need manual cleanup:',
114
- successfulUploads
115
- );
116
- }
117
-
185
+ generationLogCollector.error(sessionId, TAG, `Batch upload FAILED: ${successfulUploads.length}/${images.length} in ${elapsed}ms`, { elapsed });
118
186
  throw new Error(
119
- `Failed to upload ${failures.length} of ${images.length} image(s): ${errorMessage}`
187
+ `Failed to upload ${failures.length} of ${images.length} image(s) (${elapsed}ms): ${errorMessage}`
120
188
  );
121
189
  }
122
190
 
191
+ generationLogCollector.log(sessionId, TAG, `Batch upload complete: ${images.length}/${images.length} in ${elapsed}ms`, { elapsed });
123
192
  return successfulUploads;
124
193
  }
@@ -41,6 +41,9 @@ export {
41
41
 
42
42
  export { preprocessInput } from "./input-preprocessor.util";
43
43
 
44
+ export { generationLogCollector } from "./log-collector";
45
+ export type { LogEntry } from "./log-collector";
46
+
44
47
  export { FalGenerationStateManager } from "./fal-generation-state-manager.util";
45
48
  export type { GenerationState } from "./fal-generation-state-manager.util";
46
49
 
@@ -1,183 +1,174 @@
1
1
  /**
2
2
  * Input Preprocessor Utility
3
3
  * Detects and uploads base64/local file images to FAL storage before API calls
4
+ *
5
+ * Upload strategy:
6
+ * - Array fields (image_urls): SEQUENTIAL uploads to avoid bandwidth contention
7
+ * - Individual fields (image_url, face_image_url): parallel (typically only 1)
4
8
  */
5
9
 
6
10
  import { uploadToFalStorage, uploadLocalFileToFalStorage } from "./fal-storage.util";
7
11
  import { getErrorMessage } from './helpers/error-helpers.util';
8
12
  import { IMAGE_URL_FIELDS } from './constants/image-fields.constants';
9
13
  import { isImageDataUri as isBase64DataUri } from './validators/data-uri-validator.util';
14
+ import { generationLogCollector } from './log-collector';
15
+
16
+ const TAG = 'preprocessor';
10
17
 
11
- /**
12
- * Check if a value is a local file URI (file:// or content://)
13
- */
14
18
  function isLocalFileUri(value: unknown): value is string {
15
19
  return typeof value === "string" && (
16
20
  value.startsWith("file://") || value.startsWith("content://")
17
21
  );
18
22
  }
19
23
 
24
+ /**
25
+ * Classify a network error into a user-friendly message.
26
+ * Technical details are preserved in Firestore logs/session subcollection.
27
+ */
28
+ function classifyUploadError(errorMsg: string): string {
29
+ const lower = errorMsg.toLowerCase();
30
+
31
+ if (lower.includes('timed out') || lower.includes('timeout')) {
32
+ return 'Photo upload took too long. Please try again on a stronger connection (WiFi recommended).';
33
+ }
34
+ if (lower.includes('network request failed') || lower.includes('network') || lower.includes('fetch')) {
35
+ return 'Photo upload failed due to network issues. Please check your internet connection and try again.';
36
+ }
37
+ if (lower.includes('econnrefused') || lower.includes('enotfound')) {
38
+ return 'Could not reach the upload server. Please check your internet connection and try again.';
39
+ }
40
+
41
+ return errorMsg;
42
+ }
43
+
20
44
  /**
21
45
  * Preprocess input by uploading base64/local file images to FAL storage.
22
46
  * Also strips sync_mode to prevent base64 data URI responses.
23
47
  * Returns input with HTTPS URLs instead of base64/local URIs.
48
+ *
49
+ * Array fields are uploaded SEQUENTIALLY to avoid bandwidth contention
50
+ * on slow mobile connections (prevents simultaneous upload failures).
24
51
  */
25
52
  export async function preprocessInput(
26
53
  input: Record<string, unknown>,
54
+ sessionId: string,
27
55
  ): Promise<Record<string, unknown>> {
28
- if (typeof __DEV__ !== "undefined" && __DEV__) {
29
- console.log("[preprocessInput] Starting input preprocessing...", {
30
- keys: Object.keys(input)
31
- });
32
- }
56
+ const startTime = Date.now();
57
+ const inputKeys = Object.keys(input);
58
+ generationLogCollector.log(sessionId, TAG, `Starting preprocessing...`, { keys: inputKeys });
33
59
 
34
60
  const result = { ...input };
35
61
  const uploadPromises: Promise<unknown>[] = [];
36
62
 
37
- // SAFETY: Strip sync_mode to prevent base64 data URI responses
38
- // FAL returns base64 when sync_mode:true — we always want CDN URLs
39
63
  if ("sync_mode" in result) {
40
64
  delete result.sync_mode;
41
- if (typeof __DEV__ !== "undefined" && __DEV__) {
42
- console.warn(
43
- "[preprocessInput] Stripped sync_mode from input. " +
44
- "sync_mode:true returns base64 data URIs which break Firestore persistence. " +
45
- "Use falProvider.subscribe() for CDN URLs."
46
- );
47
- }
65
+ generationLogCollector.warn(sessionId, TAG, `Stripped sync_mode from input`);
48
66
  }
49
67
 
50
- // Handle individual image URL keys
68
+ // Handle individual image URL keys (parallel — typically only 1 field)
69
+ let individualUploadCount = 0;
51
70
  for (const key of IMAGE_URL_FIELDS) {
52
71
  const value = result[key];
53
72
 
54
- // Upload base64 data URIs to FAL storage
55
73
  if (isBase64DataUri(value)) {
56
- const uploadPromise = uploadToFalStorage(value)
74
+ individualUploadCount++;
75
+ generationLogCollector.log(sessionId, TAG, `Found base64 field: ${key} (${Math.round(String(value).length / 1024)}KB)`);
76
+ const uploadPromise = uploadToFalStorage(value, sessionId)
57
77
  .then((url) => {
58
78
  result[key] = url;
59
79
  return url;
60
80
  })
61
81
  .catch((error) => {
62
82
  const errorMessage = `Failed to upload ${key}: ${getErrorMessage(error)}`;
63
- console.error(`[preprocessInput] ${errorMessage}`);
83
+ generationLogCollector.error(sessionId, TAG, errorMessage);
64
84
  throw new Error(errorMessage);
65
85
  });
66
-
67
86
  uploadPromises.push(uploadPromise);
68
- }
69
- // Upload local file URIs to FAL storage (file://, content://)
70
- else if (isLocalFileUri(value)) {
71
- const uploadPromise = uploadLocalFileToFalStorage(value)
87
+ } else if (isLocalFileUri(value)) {
88
+ individualUploadCount++;
89
+ generationLogCollector.log(sessionId, TAG, `Found local file field: ${key}`);
90
+ const uploadPromise = uploadLocalFileToFalStorage(value, sessionId)
72
91
  .then((url) => {
73
92
  result[key] = url;
74
93
  return url;
75
94
  })
76
95
  .catch((error) => {
77
96
  const errorMessage = `Failed to upload local file ${key}: ${getErrorMessage(error)}`;
78
- console.error(`[preprocessInput] ${errorMessage}`);
97
+ generationLogCollector.error(sessionId, TAG, errorMessage);
79
98
  throw new Error(errorMessage);
80
99
  });
81
-
82
100
  uploadPromises.push(uploadPromise);
83
101
  }
84
102
  }
85
103
 
104
+ if (individualUploadCount > 0) {
105
+ generationLogCollector.log(sessionId, TAG, `${individualUploadCount} individual field upload(s) queued`);
106
+ }
86
107
 
87
- // Handle image URL arrays (image_urls, input_image_urls, reference_image_urls)
108
+ // Handle image URL arrays SEQUENTIAL to avoid bandwidth contention
88
109
  for (const arrayField of ["image_urls", "input_image_urls", "reference_image_urls"] as const) {
89
110
  if (Array.isArray(result[arrayField]) && (result[arrayField] as unknown[]).length > 0) {
90
111
  const imageUrls = result[arrayField] as unknown[];
91
- const uploadTasks: Array<{ index: number; url: string | Promise<string> }> = [];
92
- const errors: string[] = [];
112
+ generationLogCollector.log(sessionId, TAG, `Processing ${arrayField}: ${imageUrls.length} item(s) (sequential)`);
113
+
114
+ const processedUrls: string[] = [];
115
+ const arrayStartTime = Date.now();
93
116
 
94
117
  for (let i = 0; i < imageUrls.length; i++) {
95
118
  const imageUrl = imageUrls[i];
96
119
 
97
120
  if (!imageUrl) {
98
- errors.push(`${arrayField}[${i}] is null or undefined`);
99
- continue;
121
+ throw new Error(`${arrayField}[${i}] is null or undefined`);
100
122
  }
101
123
 
102
124
  if (isBase64DataUri(imageUrl)) {
103
- const uploadPromise = uploadToFalStorage(imageUrl)
104
- .then((url) => url)
105
- .catch((error) => {
106
- const errorMessage = `Failed to upload ${arrayField}[${i}]: ${getErrorMessage(error)}`;
107
- console.error(`[preprocessInput] ${errorMessage}`);
108
- errors.push(errorMessage);
109
- throw new Error(errorMessage);
110
- });
111
- uploadTasks.push({ index: i, url: uploadPromise });
125
+ const sizeKB = Math.round(String(imageUrl).length / 1024);
126
+ generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: base64 (${sizeKB}KB) - uploading...`);
127
+
128
+ try {
129
+ const url = await uploadToFalStorage(imageUrl, sessionId);
130
+ processedUrls.push(url);
131
+ generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: upload OK`);
132
+ } catch (error) {
133
+ const elapsed = Date.now() - arrayStartTime;
134
+ const technicalMsg = getErrorMessage(error);
135
+ generationLogCollector.error(sessionId, TAG, `${arrayField}[${i}] upload FAILED after ${elapsed}ms: ${technicalMsg}`);
136
+ throw new Error(classifyUploadError(technicalMsg));
137
+ }
112
138
  } else if (typeof imageUrl === "string") {
113
- uploadTasks.push({ index: i, url: imageUrl });
114
- } else {
115
- errors.push(`${arrayField}[${i}] has invalid type: ${typeof imageUrl}`);
116
- }
117
- }
118
-
119
- if (errors.length > 0) {
120
- throw new Error(`Image URL validation failed:\n${errors.join('\n')}`);
121
- }
122
-
123
- if (uploadTasks.length === 0) {
124
- throw new Error(`${arrayField} array must contain at least one valid image URL`);
125
- }
126
-
127
- const uploadResults = await Promise.allSettled(
128
- uploadTasks.map((task) => Promise.resolve(task.url))
129
- );
130
-
131
- const processedUrls: string[] = [];
132
- const uploadErrors: string[] = [];
133
-
134
- uploadResults.forEach((uploadResult, index) => {
135
- if (uploadResult.status === 'fulfilled') {
136
- processedUrls.push(uploadResult.value);
139
+ generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: already URL - pass through`);
140
+ processedUrls.push(imageUrl);
137
141
  } else {
138
- uploadErrors.push(
139
- `Upload ${index} failed: ${getErrorMessage(uploadResult.reason)}`
140
- );
142
+ throw new Error(`${arrayField}[${i}] has invalid type: ${typeof imageUrl}`);
141
143
  }
142
- });
143
-
144
- if (uploadErrors.length > 0) {
145
- console.warn(
146
- `[input-preprocessor] ${processedUrls.length} of ${uploadTasks.length} uploads succeeded. ` +
147
- 'Successful uploads remain in FAL storage.'
148
- );
149
- throw new Error(`Image upload failures:\n${uploadErrors.join('\n')}`);
150
144
  }
151
145
 
146
+ const arrayElapsed = Date.now() - arrayStartTime;
147
+ generationLogCollector.log(sessionId, TAG, `${arrayField}: all ${processedUrls.length} upload(s) succeeded in ${arrayElapsed}ms`);
152
148
  result[arrayField] = processedUrls;
153
149
  }
154
150
  }
155
151
 
156
- // Wait for ALL uploads to complete (both individual keys and array)
157
- // Use Promise.allSettled to handle partial failures gracefully
152
+ // Wait for ALL individual field uploads
158
153
  if (uploadPromises.length > 0) {
154
+ generationLogCollector.log(sessionId, TAG, `Waiting for ${uploadPromises.length} individual field upload(s)...`);
159
155
  const individualUploadResults = await Promise.allSettled(uploadPromises);
160
156
 
161
157
  const failedUploads = individualUploadResults.filter(
162
- (result) => result.status === 'rejected'
158
+ (r) => r.status === 'rejected'
163
159
  );
164
160
 
165
161
  if (failedUploads.length > 0) {
166
162
  const successCount = individualUploadResults.length - failedUploads.length;
167
- console.warn(
168
- `[input-preprocessor] ${successCount} of ${individualUploadResults.length} individual field uploads succeeded. ` +
169
- 'Successful uploads remain in FAL storage.'
170
- );
171
-
172
- const errorMessages = failedUploads.map((result) =>
173
- result.status === 'rejected'
174
- ? (getErrorMessage(result.reason))
175
- : 'Unknown error'
163
+ const errorMessages = failedUploads.map((r) =>
164
+ r.status === 'rejected' ? getErrorMessage(r.reason) : 'Unknown error'
176
165
  );
177
-
178
- throw new Error(`Some image uploads failed:\n${errorMessages.join('\n')}`);
166
+ generationLogCollector.error(sessionId, TAG, `Individual uploads: ${successCount}/${individualUploadResults.length} succeeded`, { errors: errorMessages });
167
+ throw new Error(classifyUploadError(errorMessages[0]));
179
168
  }
180
169
  }
181
170
 
171
+ const totalElapsed = Date.now() - startTime;
172
+ generationLogCollector.log(sessionId, TAG, `Preprocessing complete in ${totalElapsed}ms`);
182
173
  return result;
183
174
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Generation Log Collector
3
+ * Session-scoped log collection — each generation gets its own isolated session.
4
+ * Supports concurrent generations without data corruption.
5
+ *
6
+ * Usage:
7
+ * const sessionId = collector.startSession();
8
+ * collector.log(sessionId, 'tag', 'message');
9
+ * const entries = collector.endSession(sessionId);
10
+ */
11
+
12
+ export interface LogEntry {
13
+ readonly timestamp: number;
14
+ readonly elapsed: number;
15
+ readonly level: 'info' | 'warn' | 'error';
16
+ readonly tag: string;
17
+ readonly message: string;
18
+ readonly data?: Record<string, unknown>;
19
+ }
20
+
21
+ interface Session {
22
+ readonly startTime: number;
23
+ entries: LogEntry[];
24
+ }
25
+
26
+ let sessionCounter = 0;
27
+
28
+ class GenerationLogCollector {
29
+ private sessions = new Map<string, Session>();
30
+
31
+ /**
32
+ * Start a new isolated logging session. Returns session ID.
33
+ */
34
+ startSession(): string {
35
+ const id = `session_${++sessionCounter}_${Date.now()}`;
36
+ this.sessions.set(id, { startTime: Date.now(), entries: [] });
37
+ return id;
38
+ }
39
+
40
+ log(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
41
+ this.addEntry(sessionId, 'info', tag, message, data);
42
+ }
43
+
44
+ warn(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
45
+ this.addEntry(sessionId, 'warn', tag, message, data);
46
+ }
47
+
48
+ error(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
49
+ this.addEntry(sessionId, 'error', tag, message, data);
50
+ }
51
+
52
+ /**
53
+ * Get all entries for a session (non-destructive).
54
+ */
55
+ getEntries(sessionId: string): LogEntry[] {
56
+ return [...(this.sessions.get(sessionId)?.entries ?? [])];
57
+ }
58
+
59
+ /**
60
+ * End session and return all entries. Removes the session.
61
+ */
62
+ endSession(sessionId: string): LogEntry[] {
63
+ const session = this.sessions.get(sessionId);
64
+ if (!session) return [];
65
+ const entries = [...session.entries];
66
+ this.sessions.delete(sessionId);
67
+ return entries;
68
+ }
69
+
70
+ private addEntry(
71
+ sessionId: string,
72
+ level: LogEntry['level'],
73
+ tag: string,
74
+ message: string,
75
+ data?: Record<string, unknown>,
76
+ ): void {
77
+ const session = this.sessions.get(sessionId);
78
+ if (!session) {
79
+ // Session not started or already ended — still output to console, but don't buffer
80
+ this.consoleOutput(level, tag, message, data);
81
+ return;
82
+ }
83
+
84
+ const now = Date.now();
85
+ session.entries.push({
86
+ timestamp: now,
87
+ elapsed: now - session.startTime,
88
+ level,
89
+ tag,
90
+ message,
91
+ ...(data && { data }),
92
+ });
93
+
94
+ this.consoleOutput(level, tag, message, data);
95
+ }
96
+
97
+ private consoleOutput(level: LogEntry['level'], tag: string, message: string, data?: Record<string, unknown>): void {
98
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
99
+ const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
100
+ fn(`[${tag}] ${message}`, data ?? '');
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Module-level singleton — safe for concurrent sessions via session IDs */
106
+ export const generationLogCollector = new GenerationLogCollector();