|
1 | 1 | import fs from 'fs/promises'; |
| 2 | +import os from 'os'; |
2 | 3 | import path from 'path'; |
3 | 4 | import { Reader } from 'comapeocat'; // Added Reader import |
4 | 5 | import { buildSettingsV1 } from '../services/settingsBuilder'; |
5 | 6 | import { buildComapeoCatV2 } from '../services/comapeocatBuilder'; |
6 | 7 | import { ValidationError, ProcessingError } from '../types/errors'; |
7 | | -import type { BuildRequestV2 } from '../types/v2'; |
| 8 | +import type { BuildRequestV2, IconInput } from '../types/v2'; |
8 | 9 | import { config } from '../config/app'; |
9 | 10 |
|
10 | 11 | /** |
@@ -143,7 +144,7 @@ export async function handleBuildSettingsV2(payload: BuildRequestV2) { |
143 | 144 |
|
144 | 145 | return new Response(cleanupStream, { headers }); |
145 | 146 | } catch (error) { |
146 | | - logV2PayloadForFailure(payload, error); |
| 147 | + await logV2PayloadForFailure(payload, error); |
147 | 148 | throw error; |
148 | 149 | } |
149 | 150 | } |
@@ -183,11 +184,160 @@ function formatWarningsHeader(warnings: string[]) { |
183 | 184 | return joined.length > 1024 ? `${joined.slice(0, 1021)}...` : joined; |
184 | 185 | } |
185 | 186 |
|
186 | | -function logV2PayloadForFailure(payload: BuildRequestV2, error: unknown) { |
| 187 | +const PAYLOAD_LOG_PREFIX = 'comapeo-v2-payload-'; |
| 188 | +const SUMMARY_SAMPLE_LIMIT = 8; |
| 189 | +const ISSUE_SAMPLE_LIMIT = 5; |
| 190 | + |
| 191 | +async function logV2PayloadForFailure(payload: BuildRequestV2, error: unknown) { |
187 | 192 | try { |
188 | | - const serializedPayload = JSON.stringify(payload, null, 2); |
189 | | - console.error('[COMAPEO-API][ERROR] /v2 build failed. Payload dump follows:', serializedPayload, error); |
| 193 | + const summary = buildPayloadSummary(payload); |
| 194 | + const payloadPath = await persistPayloadSnapshot(payload); |
| 195 | + console.error( |
| 196 | + `[COMAPEO-API][ERROR] /v2 build failed. Summary: ${JSON.stringify(summary)}. Full payload saved to: ${payloadPath}`, |
| 197 | + error |
| 198 | + ); |
190 | 199 | } catch (serializationError) { |
191 | | - console.error('[COMAPEO-API][ERROR] /v2 build failed and payload serialization threw an error:', serializationError, error); |
| 200 | + console.error( |
| 201 | + '[COMAPEO-API][ERROR] /v2 build failed and payload logging also failed:', |
| 202 | + serializationError, |
| 203 | + error |
| 204 | + ); |
192 | 205 | } |
193 | 206 | } |
| 207 | + |
| 208 | +function buildPayloadSummary(payload: BuildRequestV2) { |
| 209 | + const categories = Array.isArray(payload.categories) ? payload.categories : []; |
| 210 | + const fields = Array.isArray(payload.fields) ? payload.fields : []; |
| 211 | + const icons = Array.isArray(payload.icons) ? payload.icons : []; |
| 212 | + const translationLocales = payload.translations && typeof payload.translations === 'object' |
| 213 | + ? Object.keys(payload.translations) |
| 214 | + : []; |
| 215 | + |
| 216 | + const categoryIds = categories.map((c) => c.id).filter(Boolean); |
| 217 | + const fieldIds = fields.map((f) => f.id).filter(Boolean); |
| 218 | + const localesSample = truncateList(translationLocales, SUMMARY_SAMPLE_LIMIT); |
| 219 | + const categoriesWithoutFields = categories |
| 220 | + .filter((category) => !Array.isArray(category.fields) && !Array.isArray(category.defaultFieldIds)) |
| 221 | + .map((category) => category.id); |
| 222 | + |
| 223 | + const iconIds = new Set(icons.map((icon) => icon.id).filter(Boolean)); |
| 224 | + const referencedIconIds = categories |
| 225 | + .map((category) => { |
| 226 | + const directIcon = (category as any).icon; |
| 227 | + if (typeof directIcon === 'string' && directIcon.trim()) { |
| 228 | + return directIcon.trim(); |
| 229 | + } |
| 230 | + if (typeof category.iconId === 'string' && category.iconId.trim()) { |
| 231 | + return category.iconId.trim(); |
| 232 | + } |
| 233 | + return undefined; |
| 234 | + }) |
| 235 | + .filter((value): value is string => Boolean(value)); |
| 236 | + |
| 237 | + const missingIconRefs = iconIds.size > 0 |
| 238 | + ? Array.from(new Set(referencedIconIds.filter((ref) => !iconIds.has(ref)))) |
| 239 | + : []; |
| 240 | + |
| 241 | + const potentialIssues: string[] = []; |
| 242 | + |
| 243 | + if (categoriesWithoutFields.length > 0) { |
| 244 | + potentialIssues.push( |
| 245 | + formatIssue( |
| 246 | + 'Categories without field references', |
| 247 | + categoriesWithoutFields, |
| 248 | + ISSUE_SAMPLE_LIMIT |
| 249 | + ) |
| 250 | + ); |
| 251 | + } |
| 252 | + |
| 253 | + if (missingIconRefs.length > 0) { |
| 254 | + potentialIssues.push( |
| 255 | + formatIssue('Categories referencing missing icon ids', missingIconRefs, ISSUE_SAMPLE_LIMIT) |
| 256 | + ); |
| 257 | + } |
| 258 | + |
| 259 | + const iconStats = summarizeIconStats(icons); |
| 260 | + |
| 261 | + return { |
| 262 | + metadata: { |
| 263 | + name: payload.metadata?.name || 'unknown', |
| 264 | + version: payload.metadata?.version || 'n/a', |
| 265 | + }, |
| 266 | + counts: { |
| 267 | + categories: categories.length, |
| 268 | + fields: fields.length, |
| 269 | + icons: icons.length, |
| 270 | + translations: translationLocales.length, |
| 271 | + }, |
| 272 | + samples: { |
| 273 | + categoryIds: truncateList(categoryIds, SUMMARY_SAMPLE_LIMIT), |
| 274 | + fieldIds: truncateList(fieldIds, SUMMARY_SAMPLE_LIMIT), |
| 275 | + locales: localesSample, |
| 276 | + }, |
| 277 | + potentialIssues, |
| 278 | + iconStats, |
| 279 | + }; |
| 280 | +} |
| 281 | + |
| 282 | +function summarizeIconStats(icons: IconInput[]) { |
| 283 | + if (!icons || icons.length === 0) { |
| 284 | + return undefined; |
| 285 | + } |
| 286 | + |
| 287 | + let inlineCount = 0; |
| 288 | + let dataUriCount = 0; |
| 289 | + let remoteCount = 0; |
| 290 | + let missingSourceCount = 0; |
| 291 | + let inlineBytesTotal = 0; |
| 292 | + let inlineBytesMax = 0; |
| 293 | + |
| 294 | + for (const icon of icons) { |
| 295 | + if (typeof icon.svgData === 'string') { |
| 296 | + inlineCount += 1; |
| 297 | + const size = Buffer.byteLength(icon.svgData, 'utf-8'); |
| 298 | + inlineBytesTotal += size; |
| 299 | + inlineBytesMax = Math.max(inlineBytesMax, size); |
| 300 | + } else if (typeof icon.svgUrl === 'string') { |
| 301 | + if (icon.svgUrl.startsWith('data:image/svg+xml')) { |
| 302 | + dataUriCount += 1; |
| 303 | + } else { |
| 304 | + remoteCount += 1; |
| 305 | + } |
| 306 | + } else { |
| 307 | + missingSourceCount += 1; |
| 308 | + } |
| 309 | + } |
| 310 | + |
| 311 | + const inlineAvg = inlineCount > 0 ? Math.round(inlineBytesTotal / inlineCount) : 0; |
| 312 | + |
| 313 | + return { |
| 314 | + inlineCount, |
| 315 | + dataUriCount, |
| 316 | + remoteCount, |
| 317 | + missingSourceCount, |
| 318 | + inlineBytesTotal, |
| 319 | + inlineBytesMax, |
| 320 | + inlineBytesAvg: inlineAvg, |
| 321 | + }; |
| 322 | +} |
| 323 | + |
| 324 | +function formatIssue(label: string, values: string[], limit: number) { |
| 325 | + const uniqueValues = Array.from(new Set(values)); |
| 326 | + const sample = truncateList(uniqueValues, limit); |
| 327 | + const excess = uniqueValues.length - sample.length; |
| 328 | + return excess > 0 ? `${label}: ${sample.join(', ')} (+${excess} more)` : `${label}: ${sample.join(', ')}`; |
| 329 | +} |
| 330 | + |
| 331 | +function truncateList<T>(values: T[], limit: number): T[] { |
| 332 | + if (!Array.isArray(values) || values.length <= limit) { |
| 333 | + return values.slice(); |
| 334 | + } |
| 335 | + return values.slice(0, limit); |
| 336 | +} |
| 337 | + |
| 338 | +async function persistPayloadSnapshot(payload: BuildRequestV2) { |
| 339 | + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), PAYLOAD_LOG_PREFIX)); |
| 340 | + const filePath = path.join(tempDir, `payload-${Date.now()}.json`); |
| 341 | + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf-8'); |
| 342 | + return filePath; |
| 343 | +} |
0 commit comments