@murumets-ee/imports 0.51.0 → 0.53.0
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/dist/analyze/index.d.mts +265 -0
- package/dist/analyze/index.d.mts.map +1 -0
- package/dist/analyze/index.mjs +2 -0
- package/dist/analyze/index.mjs.map +1 -0
- package/dist/carmaker-feed-B-QYr3PI.mjs +2 -0
- package/dist/{carmaker-feed-C_MnDaOC.mjs.map → carmaker-feed-B-QYr3PI.mjs.map} +1 -1
- package/dist/{carmaker-feed-DlD2xPs0.d.mts → carmaker-feed-_QVQFLUl.d.mts} +3 -2
- package/dist/carmaker-feed-_QVQFLUl.d.mts.map +1 -0
- package/dist/{cleanup-DdILFoAP.mjs → cleanup-DDljBe0Z.mjs} +2 -2
- package/dist/{cleanup-DdILFoAP.mjs.map → cleanup-DDljBe0Z.mjs.map} +1 -1
- package/dist/engine-B0ExE4jL.mjs +2 -0
- package/dist/engine-B0ExE4jL.mjs.map +1 -0
- package/dist/{parts-schema-u5kr5zFg.d.mts → engine-DiM4r2Vj.d.mts} +2 -58
- package/dist/engine-DiM4r2Vj.d.mts.map +1 -0
- package/dist/index-Bm69N5DW.d.mts +97 -0
- package/dist/index-Bm69N5DW.d.mts.map +1 -0
- package/dist/index.d.mts +26 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/mapping/index.d.mts +2 -1
- package/dist/mapping/index.d.mts.map +1 -1
- package/dist/mapping/index.mjs +1 -1
- package/dist/mapping-transform-CO4MNhMe.mjs +2 -0
- package/dist/{mapping-transform-AMFEg1rS.mjs.map → mapping-transform-CO4MNhMe.mjs.map} +1 -1
- package/dist/matching/index.d.mts +2 -0
- package/dist/matching/index.mjs +1 -0
- package/dist/matching-Sr0z4HvN.mjs +2 -0
- package/dist/matching-Sr0z4HvN.mjs.map +1 -0
- package/dist/parts-schema-DOWhBJYh.d.mts +61 -0
- package/dist/parts-schema-DOWhBJYh.d.mts.map +1 -0
- package/dist/parts-schema-QqLci_Pl.mjs +2 -0
- package/dist/parts-schema-QqLci_Pl.mjs.map +1 -0
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/read-feed-DGjqejw3.mjs +2 -0
- package/dist/read-feed-DGjqejw3.mjs.map +1 -0
- package/dist/runner-C9GIe5Dr.mjs +2 -0
- package/dist/runner-C9GIe5Dr.mjs.map +1 -0
- package/dist/{schema-registry-B_7AweTa.mjs → schema-registry-DB5aIZMN.mjs} +2 -2
- package/dist/{schema-registry-B_7AweTa.mjs.map → schema-registry-DB5aIZMN.mjs.map} +1 -1
- package/dist/storage-resolver.d.mts +1 -1
- package/dist/transform-Cu5YEoOl.mjs +2 -0
- package/dist/transform-Cu5YEoOl.mjs.map +1 -0
- package/dist/transforms.d.mts +1 -1
- package/dist/transforms.mjs +1 -1
- package/dist/{worker-DH3SN-91.d.mts → worker-_MlRH5hd.d.mts} +21 -5
- package/dist/worker-_MlRH5hd.d.mts.map +1 -0
- package/dist/worker.d.mts +1 -1
- package/dist/worker.mjs +1 -1
- package/dist/worker.mjs.map +1 -1
- package/dist/xlsx-reader-_ioPAqm5.mjs +2 -0
- package/dist/{xlsx-reader-Dsq_ERiB.mjs.map → xlsx-reader-_ioPAqm5.mjs.map} +1 -1
- package/package.json +16 -8
- package/dist/carmaker-feed-C_MnDaOC.mjs +0 -2
- package/dist/carmaker-feed-DlD2xPs0.d.mts.map +0 -1
- package/dist/mapping-transform-AMFEg1rS.mjs +0 -2
- package/dist/parts-schema-BBUINSdN.mjs +0 -2
- package/dist/parts-schema-BBUINSdN.mjs.map +0 -1
- package/dist/parts-schema-u5kr5zFg.d.mts.map +0 -1
- package/dist/runner-wPnCZlB4.mjs +0 -2
- package/dist/runner-wPnCZlB4.mjs.map +0 -1
- package/dist/transform-DwAQ4b-j.mjs +0 -2
- package/dist/transform-DwAQ4b-j.mjs.map +0 -1
- package/dist/worker-DH3SN-91.d.mts.map +0 -1
- package/dist/xlsx-reader-Dsq_ERiB.mjs +0 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleanup-DdILFoAP.mjs","names":[],"sources":["../src/admin/cleanup.ts"],"sourcesContent":["/**\n * Cleanup orphaned imports uploads.\n *\n * Finds rows in `toolkit_files` within the configured imports bucket\n * that:\n * - were created more than `ttlHours` ago, AND\n * - have no matching `import_run.filePath` reference\n *\n * For each match, calls `StorageClient.delete(key)` — which removes\n * BOTH the adapter bytes (local file / R2 object) AND the\n * `toolkit_files` metadata row in one transactional unit (see\n * `@murumets-ee/storage` `StorageClient.delete`).\n *\n * Why this exists: the Tus upload flow stores bytes + metadata BEFORE\n * the client's downstream `register` step inserts an `import_run` row.\n * If the register call fails (validation, MIME mismatch, network\n * blip, agent abandons the workflow), the storage row is left\n * dangling. The commerce-side register handler does opportunistic\n * cleanup on its OWN failure paths, but rejections from the Zod\n * schema (before the handler runs), rejections where the agent\n * never calls register at all, AND the handler's operator-fixable\n * mapping rejections (mapping not-found / no-mapping 422 / structure\n * drift 409 — which deliberately KEEP the valid upload for a re-run)\n * all rely on this sweep.\n *\n * Scheduling: driven by the `defineJob({ schedule: '@every 1h' })`\n * declaration in `plugin.ts`. The queue worker bootstraps a\n * `toolkit_scheduled_jobs` row for this job at startup and its cron\n * loop (`processDueSchedules`) fires the registered handler on each\n * tick. The handler does NOT re-enqueue itself — the schedule is\n * driven by the worker, not the handler.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { getImportsStorage } from '../storage.js'\n\nexport interface CleanupOrphanedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /**\n * Bucket name the imports flow uses. Rows in `toolkit_files` are\n * filtered to this bucket so the cleanup doesn't touch media,\n * ticketing attachments, or any other consumer's uploads even if\n * they share the same Postgres instance.\n */\n bucket: string\n /**\n * Hours after which an unreferenced upload is considered orphaned.\n * Operator-grace window: should be long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload, but\n * short enough that storage waste from failed uploads doesn't\n * accumulate. 2 hours is the default in the imports plugin\n * config — calibrated against the typical \"operator clicks\n * upload, walks away, comes back to check\" workflow.\n */\n ttlHours: number\n /**\n * Maximum rows to process per run. Bounded so a sudden flood of\n * orphans (e.g. after a brand-new supplier with unrecognised\n * filenames floods the queue with rejected uploads) doesn't\n * block the queue worker on a single multi-hour cleanup.\n */\n limit?: number\n /** Logger for diagnostics. */\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/** Default per-run cap. Tuned against ticketing's `cleanupOrphanedUploads` (also 100). */\nconst DEFAULT_LIMIT = 100\n\n/** Hard cap on per-run limit. Protects against pathological caller input. */\nconst MAX_LIMIT = 1000\n\n/**\n * Validate the numeric inputs every cleanup helper accepts. Returns\n * the sanitised values OR throws — the queue worker logs thrown\n * errors against the scheduled-job row so a misconfigured TTL\n * doesn't silently delete every bucket file (which would happen if\n * a negative `ttlHours` reached the date arithmetic and pushed the\n * cutoff into the future).\n */\nfunction validateCleanupArgs(\n raw: { ttlOrRetention: number; ttlLabel: string; limit?: number },\n): { ttlOrRetention: number; limit: number } {\n const { ttlOrRetention, ttlLabel, limit = DEFAULT_LIMIT } = raw\n if (!Number.isFinite(ttlOrRetention) || ttlOrRetention < 0) {\n throw new Error(\n `imports cleanup: ${ttlLabel} must be a finite, non-negative number (got ${ttlOrRetention})`,\n )\n }\n if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `imports cleanup: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n return { ttlOrRetention, limit }\n}\n\n/**\n * Run one cleanup pass. Returns the count of rows actually deleted\n * (which may be less than the count of orphans found if some\n * `storage.delete` calls failed — see the `warn` log inside the loop).\n */\nexport async function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: ttlHours, limit } = validateCleanupArgs({\n ttlOrRetention: args.ttlHours,\n ttlLabel: 'ttlHours',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n // Cross-table query: `toolkit_files` LEFT JOIN `import_run` — same\n // documented exception for cross-entity JOINs that ticketing's\n // cleanup uses. Two-table joins can't go through a single-entity\n // AdminClient (per CLAUDE.md \"Database Queries\").\n const { and, asc, eq, isNull, lt, getTableColumns } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n // Both `toolkitFiles` and the `importRunsTable` are exported as\n // generic PgTables so direct dot-access widens to `T | undefined`.\n // Pull the column refs through `getTableColumns` (same shape as\n // ticketing's cleanup + the entity-version helper).\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n const fileCreatedAt = fileCols.createdAt\n if (!fileKey || !fileBucket || !fileCreatedAt) {\n throw new Error(\n 'cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irId = importRunCols.id\n if (!irFilePath || !irId) {\n throw new Error(\n 'cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)',\n )\n }\n\n const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000)\n\n // Find toolkit_files rows in the imports bucket, older than cutoff,\n // where the storage key is NOT referenced by any import_run row.\n // ORDER BY createdAt ASC delivers strict FIFO so a sustained\n // orphan stream makes deterministic progress on the oldest rows\n // first — without the orderBy, Postgres returns rows in heap\n // order and a LIMIT 100 sweep could keep recycling the same\n // young rows while ancient orphans never get touched.\n const orphans = await db\n .select({ key: fileKey })\n .from(toolkitFiles)\n .leftJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(and(eq(fileBucket, bucket), lt(fileCreatedAt, cutoff), isNull(irId)))\n .orderBy(asc(fileCreatedAt))\n .limit(limit)\n\n if (orphans.length === 0) {\n logger.debug({ bucket, ttlHours }, 'No orphaned imports uploads found')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of orphans) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete orphaned imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: orphans.length, bucket, ttlHours },\n 'Cleaned up orphaned imports uploads',\n )\n return cleaned\n}\n\n/** Statuses considered \"done\" for the post-success/failure cleanup. */\nconst TERMINAL_IMPORT_RUN_STATUSES = ['succeeded', 'failed', 'cancelled'] as const\n\nexport interface CleanupProcessedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */\n bucket: string\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted. Operator-grace window: long enough that an operator can\n * inspect the source file if a transform bug surfaces shortly after\n * a successful import; short enough that storage doesn't accumulate\n * months of supplier feeds.\n *\n * The plugin default is 7. Set to 0 to delete the source file\n * immediately at the next cleanup tick after the import row\n * reaches terminal status — useful when storage cost matters more\n * than forensic reprocess capability.\n */\n retentionDays: number\n /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */\n limit?: number\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/**\n * Cleanup source files for `import_run`s that finished (succeeded /\n * failed / cancelled) more than `retentionDays` days ago.\n *\n * Counterpart to `cleanupOrphanedImports`: that one targets uploads\n * with NO matching `import_run` (handshake never completed); this one\n * targets uploads whose `import_run` row IS present and terminal.\n * Together they cover every \"useless storage row\" case the imports\n * flow can produce.\n *\n * Deletes ONLY the storage row (via `StorageClient.delete`); the\n * `import_run` row is kept so the History panel + audit trail still\n * surface the past run. The `import_run.filePath` value points at a\n * now-deleted key after this runs — consumers that try to re-download\n * the source file (e.g. a future \"reprocess\" button) will get a\n * `getMetadata` → null and should show \"source file unavailable\" in\n * the UI rather than crashing.\n */\nexport async function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: retentionDays, limit } = validateCleanupArgs({\n ttlOrRetention: args.retentionDays,\n ttlLabel: 'retentionDays',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n const { and, asc, eq, getTableColumns, inArray, lt } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n if (!fileKey || !fileBucket) {\n throw new Error(\n 'cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irStatus = importRunCols.status\n const irFinishedAt = importRunCols.finishedAt\n if (!irFilePath || !irStatus || !irFinishedAt) {\n throw new Error(\n 'cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)',\n )\n }\n\n const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)\n\n // INNER JOIN so we only consider keys that DO have a matching\n // import_run row — the inverse of the orphan-cleanup query. Filter\n // on terminal status + finishedAt cutoff so a long-running batch\n // doesn't get its still-being-processed file ripped out from under\n // the worker.\n //\n // `selectDistinctOn([fileKey])` is the right primitive here:\n //\n // (a) **Dedupe before LIMIT.** `toolkit_files.key` is UNIQUE\n // within its own table, but `import_run.filePath` has no\n // unique constraint — a future re-run / retry flow that\n // creates two terminal `import_run` rows pointing at the\n // same `storageKey` would make the INNER JOIN project the\n // key twice. Without dedupe in SQL, a pathological cluster\n // of 100 duplicates sharing 1 storageKey would burn the\n // LIMIT cap to clean 1 file.\n //\n // (b) **Deterministic ordering** (NOT strict global FIFO).\n // `DISTINCT ON` requires the ORDER BY to start with the\n // distinct columns (Postgres rule). So we sort by `fileKey`\n // first, then `irFinishedAt` within each key group. The\n // LIMIT then picks the first N keys in `fileKey` order —\n // i.e. alphabetical by storage path, not globally oldest by\n // finishedAt. In practice this is roughly chronological\n // anyway: storage keys are `uploads/YYYY/MM/<uuid>/...` so\n // earlier months sort first. Within a month, ordering is\n // UUID-random — but every key eventually gets cleaned\n // (no starvation), so over ~10 ticks the entire backlog\n // drains either way. A subquery-wrapped \"true global FIFO\"\n // (DISTINCT ON inner, ORDER BY finishedAt outer) is\n // possible but adds complexity for marginal benefit; the\n // practical-FIFO-via-key-prefix property is acceptable.\n const candidates = await db\n .selectDistinctOn([fileKey], { key: fileKey })\n .from(toolkitFiles)\n .innerJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(\n and(\n eq(fileBucket, bucket),\n inArray(irStatus, [...TERMINAL_IMPORT_RUN_STATUSES]),\n lt(irFinishedAt, cutoff),\n ),\n )\n .orderBy(asc(fileKey), asc(irFinishedAt))\n .limit(limit)\n\n if (candidates.length === 0) {\n logger.debug({ bucket, retentionDays }, 'No processed-imports uploads ready for cleanup')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of candidates) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete processed-imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: candidates.length, bucket, retentionDays },\n 'Cleaned up processed imports uploads',\n )\n return cleaned\n}\n"],"mappings":"4KAqEA,MAGM,EAAY,IAUlB,SAAS,EACP,EAC2C,CAC3C,GAAM,CAAE,iBAAgB,WAAU,QAAQ,KAAkB,EAC5D,GAAI,CAAC,OAAO,SAAS,CAAc,GAAK,EAAiB,EACvD,MAAU,MACR,oBAAoB,EAAS,8CAA8C,EAAe,EAC5F,EAEF,GAAI,CAAC,OAAO,SAAS,CAAK,GAAK,CAAC,OAAO,UAAU,CAAK,GAAK,EAAQ,GAAK,EAAQ,EAC9E,MAAU,MACR,oDAAoD,EAAU,SAAS,EAAM,EAC/E,EAEF,MAAO,CAAE,iBAAgB,OAAM,CACjC,CAOA,eAAsB,EAAuB,EAAmD,CAC9F,GAAM,CAAE,MAAK,SAAQ,UAAW,EAC1B,EAAK,EAAI,GAAG,UACZ,CAAE,eAAgB,EAAU,SAAU,EAAoB,CAC9D,eAAgB,EAAK,SACrB,SAAU,WAIV,GAAI,EAAK,QAAU,IAAA,IAAa,CAAE,MAAO,EAAK,KAAM,CACtD,CAAC,EAMK,CAAE,MAAK,MAAK,KAAI,SAAQ,KAAI,mBAAoB,MAAM,OAAO,eAC7D,CAAE,gBAAiB,MAAM,OAAO,wBAChC,CAAE,qBAAsB,MAAM,OAAO,6BACrC,CAAE,aAAc,MAAM,OAAO,2BAA4B,CAAA,KAAA,GAAA,EAAA,CAAA,EAGzD,EAAkB,EAAkB,CAAS,CAAC,CAAC,SAAS,EAMxD,EAAW,EAAgB,CAAY,EACvC,EAAU,EAAS,IACnB,EAAa,EAAS,OACtB,EAAgB,EAAS,UAC/B,GAAI,CAAC,GAAW,CAAC,GAAc,CAAC,EAC9B,MAAU,MACR,8FACF,EAEF,IAAM,EAAgB,EAAgB,CAAe,EAC/C,EAAa,EAAc,SAC3B,EAAO,EAAc,GAC3B,GAAI,CAAC,GAAc,CAAC,EAClB,MAAU,MACR,kFACF,EAGF,IAAM,EAAS,IAAI,KAAK,KAAK,IAAI,EAAI,EAAW,GAAK,GAAK,GAAI,EASxD,EAAU,MAAM,EACnB,OAAO,CAAE,IAAK,CAAQ,CAAC,CAAC,CACxB,KAAK,CAAY,CAAC,CAClB,SAAS,EAAiB,EAAG,EAAY,CAAO,CAAC,CAAC,CAClD,MAAM,EAAI,EAAG,EAAY,CAAM,EAAG,EAAG,EAAe,CAAM,EAAG,EAAO,CAAI,CAAC,CAAC,CAAC,CAC3E,QAAQ,EAAI,CAAa,CAAC,CAAC,CAC3B,MAAM,CAAK,EAEd,GAAI,EAAQ,SAAW,EAErB,OADA,EAAO,MAAM,CAAE,SAAQ,UAAS,EAAG,mCAAmC,EAC/D,EAGT,IAAM,EAAU,MAAM,EAAkB,EACpC,EAAU,EACd,IAAK,IAAM,KAAO,EAAS,CACzB,IAAM,EAAM,EAAI,IAChB,GAAI,CACF,MAAM,EAAQ,OAAO,CAAG,EACxB,GACF,OAAS,EAAK,CACZ,EAAO,KAAK,CAAE,MAAK,KAAI,EAAG,0CAA0C,CACtE,CACF,CAMA,OAJA,EAAO,KACL,CAAE,UAAS,MAAO,EAAQ,OAAQ,SAAQ,UAAS,EACnD,qCACF,EACO,CACT,CAGA,MAAM,EAA+B,CAAC,YAAa,SAAU,WAAW,EA2CxE,eAAsB,EAAwB,EAAoD,CAChG,GAAM,CAAE,MAAK,SAAQ,UAAW,EAC1B,EAAK,EAAI,GAAG,UACZ,CAAE,eAAgB,EAAe,SAAU,EAAoB,CACnE,eAAgB,EAAK,cACrB,SAAU,gBAIV,GAAI,EAAK,QAAU,IAAA,IAAa,CAAE,MAAO,EAAK,KAAM,CACtD,CAAC,EAEK,CAAE,MAAK,MAAK,KAAI,kBAAiB,UAAS,MAAO,MAAM,OAAO,eAC9D,CAAE,gBAAiB,MAAM,OAAO,wBAChC,CAAE,qBAAsB,MAAM,OAAO,6BACrC,CAAE,aAAc,MAAM,OAAO,2BAA4B,CAAA,KAAA,GAAA,EAAA,CAAA,EAGzD,EAAkB,EAAkB,CAAS,CAAC,CAAC,SAAS,EAExD,EAAW,EAAgB,CAAY,EACvC,EAAU,EAAS,IACnB,EAAa,EAAS,OAC5B,GAAI,CAAC,GAAW,CAAC,EACf,MAAU,MACR,qFACF,EAEF,IAAM,EAAgB,EAAgB,CAAe,EAC/C,EAAa,EAAc,SAC3B,EAAW,EAAc,OACzB,EAAe,EAAc,WACnC,GAAI,CAAC,GAAc,CAAC,GAAY,CAAC,EAC/B,MAAU,MACR,kGACF,EAGF,IAAM,EAAS,IAAI,KAAK,KAAK,IAAI,EAAI,EAAgB,GAAK,GAAK,GAAK,GAAI,EAkClE,EAAa,MAAM,EACtB,iBAAiB,CAAC,CAAO,EAAG,CAAE,IAAK,CAAQ,CAAC,CAAC,CAC7C,KAAK,CAAY,CAAC,CAClB,UAAU,EAAiB,EAAG,EAAY,CAAO,CAAC,CAAC,CACnD,MACC,EACE,EAAG,EAAY,CAAM,EACrB,EAAQ,EAAU,CAAC,GAAG,CAA4B,CAAC,EACnD,EAAG,EAAc,CAAM,CACzB,CACF,CAAC,CACA,QAAQ,EAAI,CAAO,EAAG,EAAI,CAAY,CAAC,CAAC,CACxC,MAAM,CAAK,EAEd,GAAI,EAAW,SAAW,EAExB,OADA,EAAO,MAAM,CAAE,SAAQ,eAAc,EAAG,gDAAgD,EACjF,EAGT,IAAM,EAAU,MAAM,EAAkB,EACpC,EAAU,EACd,IAAK,IAAM,KAAO,EAAY,CAC5B,IAAM,EAAM,EAAI,IAChB,GAAI,CACF,MAAM,EAAQ,OAAO,CAAG,EACxB,GACF,OAAS,EAAK,CACZ,EAAO,KAAK,CAAE,MAAK,KAAI,EAAG,2CAA2C,CACvE,CACF,CAMA,OAJA,EAAO,KACL,CAAE,UAAS,MAAO,EAAW,OAAQ,SAAQ,eAAc,EAC3D,sCACF,EACO,CACT"}
|
|
1
|
+
{"version":3,"file":"cleanup-DDljBe0Z.mjs","names":[],"sources":["../src/admin/cleanup.ts"],"sourcesContent":["/**\n * Cleanup orphaned imports uploads.\n *\n * Finds rows in `toolkit_files` within the configured imports bucket\n * that:\n * - were created more than `ttlHours` ago, AND\n * - have no matching `import_run.filePath` reference\n *\n * For each match, calls `StorageClient.delete(key)` — which removes\n * BOTH the adapter bytes (local file / R2 object) AND the\n * `toolkit_files` metadata row in one transactional unit (see\n * `@murumets-ee/storage` `StorageClient.delete`).\n *\n * Why this exists: the Tus upload flow stores bytes + metadata BEFORE\n * the client's downstream `register` step inserts an `import_run` row.\n * If the register call fails (validation, MIME mismatch, network\n * blip, agent abandons the workflow), the storage row is left\n * dangling. The commerce-side register handler does opportunistic\n * cleanup on its OWN failure paths, but rejections from the Zod\n * schema (before the handler runs), rejections where the agent\n * never calls register at all, AND the handler's operator-fixable\n * mapping rejections (mapping not-found / no-mapping 422 / structure\n * drift 409 — which deliberately KEEP the valid upload for a re-run)\n * all rely on this sweep.\n *\n * Scheduling: driven by the `defineJob({ schedule: '@every 1h' })`\n * declaration in `plugin.ts`. The queue worker bootstraps a\n * `toolkit_scheduled_jobs` row for this job at startup and its cron\n * loop (`processDueSchedules`) fires the registered handler on each\n * tick. The handler does NOT re-enqueue itself — the schedule is\n * driven by the worker, not the handler.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { getImportsStorage } from '../storage.js'\n\nexport interface CleanupOrphanedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /**\n * Bucket name the imports flow uses. Rows in `toolkit_files` are\n * filtered to this bucket so the cleanup doesn't touch media,\n * ticketing attachments, or any other consumer's uploads even if\n * they share the same Postgres instance.\n */\n bucket: string\n /**\n * Hours after which an unreferenced upload is considered orphaned.\n * Operator-grace window: should be long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload, but\n * short enough that storage waste from failed uploads doesn't\n * accumulate. 2 hours is the default in the imports plugin\n * config — calibrated against the typical \"operator clicks\n * upload, walks away, comes back to check\" workflow.\n */\n ttlHours: number\n /**\n * Maximum rows to process per run. Bounded so a sudden flood of\n * orphans (e.g. after a brand-new supplier with unrecognised\n * filenames floods the queue with rejected uploads) doesn't\n * block the queue worker on a single multi-hour cleanup.\n */\n limit?: number\n /** Logger for diagnostics. */\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/** Default per-run cap. Tuned against ticketing's `cleanupOrphanedUploads` (also 100). */\nconst DEFAULT_LIMIT = 100\n\n/** Hard cap on per-run limit. Protects against pathological caller input. */\nconst MAX_LIMIT = 1000\n\n/**\n * Validate the numeric inputs every cleanup helper accepts. Returns\n * the sanitised values OR throws — the queue worker logs thrown\n * errors against the scheduled-job row so a misconfigured TTL\n * doesn't silently delete every bucket file (which would happen if\n * a negative `ttlHours` reached the date arithmetic and pushed the\n * cutoff into the future).\n */\nfunction validateCleanupArgs(\n raw: { ttlOrRetention: number; ttlLabel: string; limit?: number },\n): { ttlOrRetention: number; limit: number } {\n const { ttlOrRetention, ttlLabel, limit = DEFAULT_LIMIT } = raw\n if (!Number.isFinite(ttlOrRetention) || ttlOrRetention < 0) {\n throw new Error(\n `imports cleanup: ${ttlLabel} must be a finite, non-negative number (got ${ttlOrRetention})`,\n )\n }\n if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `imports cleanup: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n return { ttlOrRetention, limit }\n}\n\n/**\n * Run one cleanup pass. Returns the count of rows actually deleted\n * (which may be less than the count of orphans found if some\n * `storage.delete` calls failed — see the `warn` log inside the loop).\n */\nexport async function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: ttlHours, limit } = validateCleanupArgs({\n ttlOrRetention: args.ttlHours,\n ttlLabel: 'ttlHours',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n // Cross-table query: `toolkit_files` LEFT JOIN `import_run` — same\n // documented exception for cross-entity JOINs that ticketing's\n // cleanup uses. Two-table joins can't go through a single-entity\n // AdminClient (per CLAUDE.md \"Database Queries\").\n const { and, asc, eq, isNull, lt, getTableColumns } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n // Both `toolkitFiles` and the `importRunsTable` are exported as\n // generic PgTables so direct dot-access widens to `T | undefined`.\n // Pull the column refs through `getTableColumns` (same shape as\n // ticketing's cleanup + the entity-version helper).\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n const fileCreatedAt = fileCols.createdAt\n if (!fileKey || !fileBucket || !fileCreatedAt) {\n throw new Error(\n 'cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irId = importRunCols.id\n if (!irFilePath || !irId) {\n throw new Error(\n 'cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)',\n )\n }\n\n const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000)\n\n // Find toolkit_files rows in the imports bucket, older than cutoff,\n // where the storage key is NOT referenced by any import_run row.\n // ORDER BY createdAt ASC delivers strict FIFO so a sustained\n // orphan stream makes deterministic progress on the oldest rows\n // first — without the orderBy, Postgres returns rows in heap\n // order and a LIMIT 100 sweep could keep recycling the same\n // young rows while ancient orphans never get touched.\n const orphans = await db\n .select({ key: fileKey })\n .from(toolkitFiles)\n .leftJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(and(eq(fileBucket, bucket), lt(fileCreatedAt, cutoff), isNull(irId)))\n .orderBy(asc(fileCreatedAt))\n .limit(limit)\n\n if (orphans.length === 0) {\n logger.debug({ bucket, ttlHours }, 'No orphaned imports uploads found')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of orphans) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete orphaned imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: orphans.length, bucket, ttlHours },\n 'Cleaned up orphaned imports uploads',\n )\n return cleaned\n}\n\n/** Statuses considered \"done\" for the post-success/failure cleanup. */\nconst TERMINAL_IMPORT_RUN_STATUSES = ['succeeded', 'failed', 'cancelled'] as const\n\nexport interface CleanupProcessedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */\n bucket: string\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted. Operator-grace window: long enough that an operator can\n * inspect the source file if a transform bug surfaces shortly after\n * a successful import; short enough that storage doesn't accumulate\n * months of supplier feeds.\n *\n * The plugin default is 7. Set to 0 to delete the source file\n * immediately at the next cleanup tick after the import row\n * reaches terminal status — useful when storage cost matters more\n * than forensic reprocess capability.\n */\n retentionDays: number\n /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */\n limit?: number\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/**\n * Cleanup source files for `import_run`s that finished (succeeded /\n * failed / cancelled) more than `retentionDays` days ago.\n *\n * Counterpart to `cleanupOrphanedImports`: that one targets uploads\n * with NO matching `import_run` (handshake never completed); this one\n * targets uploads whose `import_run` row IS present and terminal.\n * Together they cover every \"useless storage row\" case the imports\n * flow can produce.\n *\n * Deletes ONLY the storage row (via `StorageClient.delete`); the\n * `import_run` row is kept so the History panel + audit trail still\n * surface the past run. The `import_run.filePath` value points at a\n * now-deleted key after this runs — consumers that try to re-download\n * the source file (e.g. a future \"reprocess\" button) will get a\n * `getMetadata` → null and should show \"source file unavailable\" in\n * the UI rather than crashing.\n */\nexport async function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: retentionDays, limit } = validateCleanupArgs({\n ttlOrRetention: args.retentionDays,\n ttlLabel: 'retentionDays',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n const { and, asc, eq, getTableColumns, inArray, lt } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n if (!fileKey || !fileBucket) {\n throw new Error(\n 'cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irStatus = importRunCols.status\n const irFinishedAt = importRunCols.finishedAt\n if (!irFilePath || !irStatus || !irFinishedAt) {\n throw new Error(\n 'cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)',\n )\n }\n\n const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)\n\n // INNER JOIN so we only consider keys that DO have a matching\n // import_run row — the inverse of the orphan-cleanup query. Filter\n // on terminal status + finishedAt cutoff so a long-running batch\n // doesn't get its still-being-processed file ripped out from under\n // the worker.\n //\n // `selectDistinctOn([fileKey])` is the right primitive here:\n //\n // (a) **Dedupe before LIMIT.** `toolkit_files.key` is UNIQUE\n // within its own table, but `import_run.filePath` has no\n // unique constraint — a future re-run / retry flow that\n // creates two terminal `import_run` rows pointing at the\n // same `storageKey` would make the INNER JOIN project the\n // key twice. Without dedupe in SQL, a pathological cluster\n // of 100 duplicates sharing 1 storageKey would burn the\n // LIMIT cap to clean 1 file.\n //\n // (b) **Deterministic ordering** (NOT strict global FIFO).\n // `DISTINCT ON` requires the ORDER BY to start with the\n // distinct columns (Postgres rule). So we sort by `fileKey`\n // first, then `irFinishedAt` within each key group. The\n // LIMIT then picks the first N keys in `fileKey` order —\n // i.e. alphabetical by storage path, not globally oldest by\n // finishedAt. In practice this is roughly chronological\n // anyway: storage keys are `uploads/YYYY/MM/<uuid>/...` so\n // earlier months sort first. Within a month, ordering is\n // UUID-random — but every key eventually gets cleaned\n // (no starvation), so over ~10 ticks the entire backlog\n // drains either way. A subquery-wrapped \"true global FIFO\"\n // (DISTINCT ON inner, ORDER BY finishedAt outer) is\n // possible but adds complexity for marginal benefit; the\n // practical-FIFO-via-key-prefix property is acceptable.\n const candidates = await db\n .selectDistinctOn([fileKey], { key: fileKey })\n .from(toolkitFiles)\n .innerJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(\n and(\n eq(fileBucket, bucket),\n inArray(irStatus, [...TERMINAL_IMPORT_RUN_STATUSES]),\n lt(irFinishedAt, cutoff),\n ),\n )\n .orderBy(asc(fileKey), asc(irFinishedAt))\n .limit(limit)\n\n if (candidates.length === 0) {\n logger.debug({ bucket, retentionDays }, 'No processed-imports uploads ready for cleanup')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of candidates) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete processed-imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: candidates.length, bucket, retentionDays },\n 'Cleaned up processed imports uploads',\n )\n return cleaned\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{c as e,s as t}from"./parts-schema-QqLci_Pl.mjs";import{z as n}from"zod";const r=n.discriminatedUnion(`op`,[n.object({op:n.literal(`trim`)}),n.object({op:n.literal(`uppercase`)}),n.object({op:n.literal(`lowercase`)}),n.object({op:n.literal(`stripPrefix`),value:n.string().min(1).max(32)}),n.object({op:n.literal(`stripSuffix`),value:n.string().min(1).max(32)}),n.object({op:n.literal(`defaultIfEmpty`),value:n.string().max(255)}),n.object({op:n.literal(`number`)}),n.object({op:n.literal(`europeanNumber`)})]),i=[{op:`trim`,label:`Trim whitespace`,hasValue:!1},{op:`uppercase`,label:`Uppercase`,hasValue:!1},{op:`lowercase`,label:`Lowercase`,hasValue:!1},{op:`stripPrefix`,label:`Strip prefix`,hasValue:!0},{op:`stripSuffix`,label:`Strip suffix`,hasValue:!0},{op:`defaultIfEmpty`,label:`Default if empty`,hasValue:!0},{op:`number`,label:`Parse number (uses number format)`,hasValue:!1},{op:`europeanNumber`,label:`Parse European number (1.234,56)`,hasValue:!1}];function a(n,r,i){let a=n;for(let n of r)if(typeof a==`string`)switch(n.op){case`trim`:a=a.trim();break;case`uppercase`:a=a.toUpperCase();break;case`lowercase`:a=a.toLowerCase();break;case`stripPrefix`:a=a.startsWith(n.value)?a.slice(n.value.length):a;break;case`stripSuffix`:a=a.endsWith(n.value)?a.slice(0,a.length-n.value.length):a;break;case`defaultIfEmpty`:a=a.trim()===``?n.value:a;break;case`number`:a=e(a,i);break;case`europeanNumber`:a=t(a);break}return a}const o=n.object({source:n.string().min(1).max(256),target:n.string().min(1).max(64),transforms:n.array(r).default([])}),s=n.object({decimal:n.enum([`.`,`,`]),thousands:n.enum([``,`.`,`,`,` `])}).refine(e=>e.decimal!==e.thousands,{message:`decimal and thousands separators must differ`,path:[`thousands`]}),c=n.object({targetSchema:n.string().min(1).max(64),numberFormat:s,columnMap:n.array(o)});function l(e,t){let n=new Set(t.fields.map(e=>e.key));for(let r of e.columnMap)if(!n.has(r.target))throw Error(`Mapping target "${r.target}" is not a field of schema "${t.name}" (allowed: ${[...n].join(`, `)})`);let r=e.numberFormat,i=e.columnMap;return async(e,n)=>{let o={};for(let t of i){let n=e[t.source]??``;o[t.target]=a(n,t.transforms,r)}return t.buildRow(o,{importRunId:n.importRunId,rowNumber:n.rowNumber,params:n.params})}}function u(e,t){let n=e.filter(e=>e.active);if(t!==null){let e=n.find(e=>e.brandId===t);if(e)return e}return n.find(e=>e.brandId===null)}export{u as a,r as c,s as i,o as n,i as o,c as r,a as s,l as t};
|
|
2
|
+
//# sourceMappingURL=engine-B0ExE4jL.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine-B0ExE4jL.mjs","names":[],"sources":["../src/mapping/column-transforms.ts","../src/mapping/engine.ts"],"sourcesContent":["/**\n * Per-column transforms for the mapping engine (commerce-demo phase 02, D005).\n *\n * A **bounded, enum-keyed** library of cleaning operations applied to a source\n * cell before it lands on a target field. This is the security model from R02:\n * transforms are declarative operations with Zod-validated params, NEVER\n * user-supplied executable code. The carmaker feed's logic decomposes exactly\n * into `stripPrefix` + `europeanNumber` (+ the schema's code-normalize).\n *\n * Number-producing ops (`number`, `europeanNumber`) are TERMINAL by convention —\n * they convert the string cell into `number | null`; string ops after them are\n * no-ops. The engine reads the result as `string | number | null` and hands it to\n * the target schema's `buildRow`.\n */\n\nimport { z } from 'zod'\nimport { type NumberFormat, parseEuropeanNumber, parseNumber } from './number-format.js'\n\n/** Max length for a strip-affix value — bounds the param, no ReDoS surface. */\nconst MAX_AFFIX_LEN = 32\n\n/**\n * Discriminated union of bounded transform ops. The TS type is INFERRED from\n * this schema so there's a single source of truth + the snapshot is validated at\n * the engine boundary.\n */\nexport const columnTransformSchema = z.discriminatedUnion('op', [\n z.object({ op: z.literal('trim') }),\n z.object({ op: z.literal('uppercase') }),\n z.object({ op: z.literal('lowercase') }),\n z.object({ op: z.literal('stripPrefix'), value: z.string().min(1).max(MAX_AFFIX_LEN) }),\n z.object({ op: z.literal('stripSuffix'), value: z.string().min(1).max(MAX_AFFIX_LEN) }),\n z.object({ op: z.literal('defaultIfEmpty'), value: z.string().max(255) }),\n z.object({ op: z.literal('number') }),\n z.object({ op: z.literal('europeanNumber') }),\n])\n\n/** One declarative cleaning operation. */\nexport type ColumnTransform = z.infer<typeof columnTransformSchema>\n\n/** The discriminator literal of a {@link ColumnTransform}. */\nexport type ColumnTransformOp = ColumnTransform['op']\n\n/** UI descriptor for one transform op: its key, a label, and whether it takes a `value` param. */\nexport interface ColumnTransformOpDescriptor {\n op: ColumnTransformOp\n label: string\n /** `true` → the op carries a `value` string param (`stripPrefix`/`stripSuffix`/`defaultIfEmpty`). */\n hasValue: boolean\n}\n\n/**\n * Static catalogue of the available per-column transform ops, for the PR3 wizard's\n * transform picker. The ORDER is the suggested UI order; `number`/`europeanNumber`\n * are terminal (see {@link applyColumnTransforms}) and labelled accordingly.\n */\nexport const COLUMN_TRANSFORM_OPS: readonly ColumnTransformOpDescriptor[] = [\n { op: 'trim', label: 'Trim whitespace', hasValue: false },\n { op: 'uppercase', label: 'Uppercase', hasValue: false },\n { op: 'lowercase', label: 'Lowercase', hasValue: false },\n { op: 'stripPrefix', label: 'Strip prefix', hasValue: true },\n { op: 'stripSuffix', label: 'Strip suffix', hasValue: true },\n { op: 'defaultIfEmpty', label: 'Default if empty', hasValue: true },\n { op: 'number', label: 'Parse number (uses number format)', hasValue: false },\n { op: 'europeanNumber', label: 'Parse European number (1.234,56)', hasValue: false },\n] as const\n\n/** The value a column carries as it moves through the transform pipeline. */\nexport type CellValue = string | number | null\n\n/**\n * Apply a transform pipeline to a raw cell, left-to-right. String ops act on the\n * string value; the `number` / `europeanNumber` ops are terminal (convert to\n * `number | null`, with `NaN` for a malformed numeric — the schema's `buildRow`\n * surfaces that as a row error). Once the value is non-string, later string ops\n * pass through unchanged.\n */\nexport function applyColumnTransforms(\n raw: string,\n transforms: readonly ColumnTransform[],\n format: NumberFormat,\n): CellValue {\n let value: CellValue = raw\n for (const t of transforms) {\n if (typeof value !== 'string') {\n // A prior number op already produced number|null — string ops are no-ops\n // past this point (number ops are terminal by convention).\n continue\n }\n switch (t.op) {\n case 'trim':\n value = value.trim()\n break\n case 'uppercase':\n value = value.toUpperCase()\n break\n case 'lowercase':\n value = value.toLowerCase()\n break\n case 'stripPrefix':\n value = value.startsWith(t.value) ? value.slice(t.value.length) : value\n break\n case 'stripSuffix':\n value = value.endsWith(t.value) ? value.slice(0, value.length - t.value.length) : value\n break\n case 'defaultIfEmpty':\n value = value.trim() === '' ? t.value : value\n break\n case 'number':\n value = parseNumber(value, format)\n break\n case 'europeanNumber':\n value = parseEuropeanNumber(value)\n break\n }\n }\n return value\n}\n","/**\n * Mapping engine (commerce-demo phase 02, D005).\n *\n * Turns a declarative `MappingConfig` (which source column → which whitelisted\n * target field, plus per-column transforms + a number format) into a\n * `RowTransform` the existing runner drives. The pipeline per row is:\n *\n * raw cells → applyColumnTransforms(per column) → MappedRow → schema.buildRow\n *\n * Two whitelists guard the boundary (R02 injection model): the `target_schema`\n * name resolves to a registered `TargetSchema`, and every `column_map[].target`\n * must be one of that schema's fields — enforced here at build time (throws),\n * before any row is processed.\n *\n * `resolveMapping` implements the D002 most-specific ladder used at enqueue time\n * to pick the `(supplier, brand)` mapping over the `(supplier, NULL)` default.\n */\n\nimport { z } from 'zod'\nimport type { RowTransform } from '../transform.js'\nimport { applyColumnTransforms, columnTransformSchema } from './column-transforms.js'\nimport type { MappedRow, TargetSchema } from './target-schema.js'\n\n/** One column-map entry. `target` is checked against the schema whitelist separately. */\nexport const mappingColumnSchema = z.object({\n /** Source header name (or positional index string for headerless feeds). */\n source: z.string().min(1).max(256),\n /** Target field key — must be in the chosen schema's whitelist (enforced in `buildMappingTransform`). */\n target: z.string().min(1).max(64),\n /** Ordered per-column cleaning ops. Defaults to none. */\n transforms: z.array(columnTransformSchema).default([]),\n})\nexport type MappingColumn = z.infer<typeof mappingColumnSchema>\n\n/**\n * Decimal + thousands separators for the `number` transform.\n *\n * The two MUST differ: `parseNumber` strips every thousands separator FIRST, so a\n * config where `decimal === thousands` (e.g. both `,`) would strip the decimal\n * separator too — `326,88` → `32688`, a silent 100× error rather than a row error.\n * Rejected at save (`assertValidMapping`) AND at run (`mappingConfigSchema`).\n */\nexport const numberFormatSchema = z\n .object({\n decimal: z.enum(['.', ',']),\n thousands: z.enum(['', '.', ',', ' ']),\n })\n .refine((f) => f.decimal !== f.thousands, {\n message: 'decimal and thousands separators must differ',\n path: ['thousands'],\n })\n\n/**\n * The portable mapping config — the subset of an `import_mapping` row the engine\n * needs. The enqueuer SNAPSHOTS this into `import_run.params.mapping` so a run\n * records the exact mapping it used (reproducible) and the transform stays a pure\n * function of `(row, ctx)` with no per-row DB I/O.\n */\nexport const mappingConfigSchema = z.object({\n targetSchema: z.string().min(1).max(64),\n numberFormat: numberFormatSchema,\n columnMap: z.array(mappingColumnSchema),\n})\nexport type MappingConfig = z.infer<typeof mappingConfigSchema>\n\n/**\n * Build a `RowTransform` from a validated mapping config + its target schema.\n *\n * Throws at BUILD time if any `column_map` target is outside the schema's\n * whitelist (deny-by-default, R02) — so a misconfigured mapping fails loudly\n * before a single row is written, rather than silently dropping a column.\n *\n * Generic over the schema's output type, so passing the concrete `partsSchema`\n * yields a `RowTransform<PartsDocument>` (the carmaker path keeps its typing); the\n * registry-resolved `TargetSchema<unknown>` yields `RowTransform<unknown>` (the\n * `commerce:mapping` path, where the output type isn't known until runtime).\n */\nexport function buildMappingTransform<TOut>(\n config: MappingConfig,\n schema: TargetSchema<TOut>,\n): RowTransform<TOut> {\n const allowed = new Set(schema.fields.map((f) => f.key))\n for (const col of config.columnMap) {\n if (!allowed.has(col.target)) {\n throw new Error(\n `Mapping target \"${col.target}\" is not a field of schema \"${schema.name}\" ` +\n `(allowed: ${[...allowed].join(', ')})`,\n )\n }\n }\n\n const format = config.numberFormat\n const columnMap = config.columnMap\n\n return async (row, ctx) => {\n const mapped: MappedRow = {}\n for (const col of columnMap) {\n // `source` is read as data from the parsed row map — never used as a key\n // into framework objects (R02). A missing source column yields `''`.\n const raw = row[col.source] ?? ''\n mapped[col.target] = applyColumnTransforms(raw, col.transforms, format)\n }\n return schema.buildRow(mapped, {\n importRunId: ctx.importRunId,\n rowNumber: ctx.rowNumber,\n params: ctx.params,\n })\n }\n}\n\n/** The minimal shape `resolveMapping` needs — satisfied by an `import_mapping` row or a test fixture. */\nexport interface ResolvableMapping {\n /** `null` = supplier default (applies to all brands). */\n brandId: string | null\n active: boolean\n}\n\n/**\n * Most-specific resolution (D002 ladder): given the candidate mappings for one\n * `(supplier, target_schema)` and the requested brand, return the brand-specific\n * mapping if one exists, else the supplier-default (`brandId === null`). Inactive\n * mappings are skipped. Returns `undefined` when nothing matches.\n */\nexport function resolveMapping<M extends ResolvableMapping>(\n mappings: readonly M[],\n brandId: string | null,\n): M | undefined {\n const active = mappings.filter((m) => m.active)\n if (brandId !== null) {\n const specific = active.find((m) => m.brandId === brandId)\n if (specific) return specific\n }\n return active.find((m) => m.brandId === null)\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { i as RowTransform, r as RowResult } from "./transform-WeiiLdYP.mjs";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { PartsDocument } from "@murumets-ee/search-elasticsearch";
|
|
4
3
|
|
|
5
4
|
//#region src/mapping/number-format.d.ts
|
|
6
5
|
/**
|
|
@@ -606,60 +605,5 @@ interface ResolvableMapping {
|
|
|
606
605
|
*/
|
|
607
606
|
declare function resolveMapping<M extends ResolvableMapping>(mappings: readonly M[], brandId: string | null): M | undefined;
|
|
608
607
|
//#endregion
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
* Params the `parts` schema reads from the run — the per-(supplier, brand)
|
|
612
|
-
* identity resolved at enqueue time. Identical to the carmaker transform's params
|
|
613
|
-
* (the carmaker path re-exports this), so brand/supplier injection + the optional
|
|
614
|
-
* supplier code-prefix strip are validated the same way everywhere.
|
|
615
|
-
*/
|
|
616
|
-
declare const partsParamsSchema: z.ZodObject<{
|
|
617
|
-
brandId: z.ZodString;
|
|
618
|
-
brandSlug: z.ZodString;
|
|
619
|
-
supplierId: z.ZodString;
|
|
620
|
-
supplierDisplayName: z.ZodString;
|
|
621
|
-
importBatchId: z.ZodString; /** Supplier code prefix to strip before normalization (e.g. `'ME-'`). Optional. */
|
|
622
|
-
supplierCodePrefix: z.ZodOptional<z.ZodString>;
|
|
623
|
-
/**
|
|
624
|
-
* Purchase-discount map for this supplier (commerce-demo PR4, phase 03 §2):
|
|
625
|
-
* `{ groupCode → discountPct }`, resolved from `supplier_discount_group` at
|
|
626
|
-
* enqueue time and snapshotted onto the run so `buildRow` stays a pure function
|
|
627
|
-
* (no per-row DB I/O; `@murumets-ee/imports` stays a leaf). Absent for direct-net
|
|
628
|
-
* suppliers + the carmaker path. Percentages are 0–100 (the entity constrains them).
|
|
629
|
-
*/
|
|
630
|
-
discountGroups: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
631
|
-
}, "strip", z.ZodTypeAny, {
|
|
632
|
-
supplierId: string;
|
|
633
|
-
brandId: string;
|
|
634
|
-
brandSlug: string;
|
|
635
|
-
supplierDisplayName: string;
|
|
636
|
-
importBatchId: string;
|
|
637
|
-
supplierCodePrefix?: string | undefined;
|
|
638
|
-
discountGroups?: Record<string, number> | undefined;
|
|
639
|
-
}, {
|
|
640
|
-
supplierId: string;
|
|
641
|
-
brandId: string;
|
|
642
|
-
brandSlug: string;
|
|
643
|
-
supplierDisplayName: string;
|
|
644
|
-
importBatchId: string;
|
|
645
|
-
supplierCodePrefix?: string | undefined;
|
|
646
|
-
discountGroups?: Record<string, number> | undefined;
|
|
647
|
-
}>;
|
|
648
|
-
type PartsParams = z.infer<typeof partsParamsSchema>;
|
|
649
|
-
/**
|
|
650
|
-
* Uppercase + dash/whitespace-stripped (D2). `*`/other chars are preserved (e.g. `*S`
|
|
651
|
-
* markers). EXPORTED so downstream consumers that key off `code_normalized` — the
|
|
652
|
-
* replacement editor (commerce-demo PR6), which normalizes admin-typed codes before
|
|
653
|
-
* matching members — use the IDENTICAL rule (R05 Q7: normalization must match across
|
|
654
|
-
* every code source or merge-on-collision silently fails to collide).
|
|
655
|
-
*/
|
|
656
|
-
declare function normalizeCode(s: string): string;
|
|
657
|
-
/**
|
|
658
|
-
* The `parts` schema. `buildRow` THROWS on invalid run params (so a misconfigured
|
|
659
|
-
* run surfaces as a transform error, matching the carmaker contract) and returns a
|
|
660
|
-
* typed `RowResult` per row otherwise.
|
|
661
|
-
*/
|
|
662
|
-
declare const partsSchema: TargetSchema<PartsDocument>;
|
|
663
|
-
//#endregion
|
|
664
|
-
export { parseNumber as A, ColumnTransform as C, columnTransformSchema as D, applyColumnTransforms as E, NumberFormat as O, CellValue as S, ColumnTransformOpDescriptor as T, TargetSchema as _, MappingColumn as a, textOrNull as b, buildMappingTransform as c, numberFormatSchema as d, resolveMapping as f, TargetField as g, NumberOutcome as h, partsSchema as i, toMinorUnits as j, parseEuropeanNumber as k, mappingColumnSchema as l, MappedRow as m, normalizeCode as n, MappingConfig as o, BuildRowContext as p, partsParamsSchema as r, ResolvableMapping as s, PartsParams as t, mappingConfigSchema as u, numberCell as v, ColumnTransformOp as w, COLUMN_TRANSFORM_OPS as x, rowError as y };
|
|
665
|
-
//# sourceMappingURL=parts-schema-u5kr5zFg.d.mts.map
|
|
608
|
+
export { columnTransformSchema as C, toMinorUnits as D, parseNumber as E, applyColumnTransforms as S, parseEuropeanNumber as T, COLUMN_TRANSFORM_OPS as _, mappingColumnSchema as a, ColumnTransformOp as b, resolveMapping as c, NumberOutcome as d, TargetField as f, textOrNull as g, rowError as h, buildMappingTransform as i, BuildRowContext as l, numberCell as m, MappingConfig as n, mappingConfigSchema as o, TargetSchema as p, ResolvableMapping as r, numberFormatSchema as s, MappingColumn as t, MappedRow as u, CellValue as v, NumberFormat as w, ColumnTransformOpDescriptor as x, ColumnTransform as y };
|
|
609
|
+
//# sourceMappingURL=engine-DiM4r2Vj.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine-DiM4r2Vj.d.mts","names":[],"sources":["../src/mapping/number-format.ts","../src/mapping/column-transforms.ts","../src/mapping/target-schema.ts","../src/mapping/engine.ts"],"mappings":";;;;;;;;AAqBA;;;;AAIW;AAsBX;;;;;;;;AAA6D;AA+B7D;;UAzDiB,YAAA;EAyDyB;EAvDxC,OAAA;EAiFc;EA/Ed,SAAS;AAAA;;AA+EkC;;;;AC9E7C;;;;;;;;;;;iBDqBgB,WAAA,CAAY,GAAA,UAAa,MAAA,EAAQ,YAAY;;;;;;;;iBA+B7C,YAAA,CAAa,KAAa;;;;;;;;;;;;;;;;;;;;;;;iBA0B1B,mBAAA,CAAoB,CAAS;;;;AAzDgB;AA+B7D;;;cCpDa,qBAAA,EAAqB,CAAA,CAAA,qBAAA,QAAA,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAYtB,eAAA,GAAkB,CAAA,CAAE,KAAK,QAAQ,qBAAA;;KAGjC,iBAAA,GAAoB,eAAe;;UAG9B,2BAAA;EACf,EAAA,EAAI,iBAAiB;EACrB,KAAA;;EAEA,QAAA;AAAA;;;;;;cAQW,oBAAA,WAA+B,2BAA2B;;KAY3D,SAAA;;;;;;;;iBASI,qBAAA,CACd,GAAA,UACA,UAAA,WAAqB,eAAA,IACrB,MAAA,EAAQ,YAAA,GACP,SAAA;;;;UC/Dc,WAAA;EF6B4C;EE3B3D,GAAA;EF0D0B;EExD1B,KAAA;EFwDwC;EEtDxC,QAAA;EFgFc;EE9Ed,IAAA;;;AF8E2C;;;;AC9E7C;;;;;;;;;ECgBE,OAAA;AAAA;;KAIU,SAAA,GAAY,MAAM,SAAS,SAAA;;UAGtB,eAAA;EACf,WAAA;EACA,SAAA;;;;;EAKA,MAAA,EAAQ,MAAM;AAAA;;UAIC,YAAA;;EAEf,IAAA;;EAEA,MAAA,WAAiB,WAAA;;EAEjB,QAAA,CAAS,MAAA,EAAQ,SAAA,EAAW,GAAA,EAAK,eAAA,GAAkB,SAAA,CAAU,IAAA;AAAA;;;;;;iBAY/C,UAAA,CAAW,CAAwB,EAArB,SAAS;;KAO3B,aAAA;EACN,IAAA;EAAe,KAAA;AAAA;EACf,IAAA;AAAA;EACA,IAAA;AAAA;;;;;;iBAOU,UAAA,CAAW,CAAA,EAAG,SAAA,eAAwB,aAAa;;iBASnD,QAAA,CAAS,SAAA,UAAmB,OAAA,UAAiB,KAAA;;;;;;;;;;;cChFhD,mBAAA,EAAmB,CAAA,CAAA,SAAA;EHsDU,iGA0BP;uBAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KGxEjC,aAAA,GAAgB,CAAA,CAAE,KAAK,QAAQ,mBAAA;;;;;;;;;cAU9B,kBAAA,EAAkB,CAAA,CAAA,UAAA,CAAA,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;cAgBlB,mBAAA,EAAmB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;IFFuC,iGAY3D;yBAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KELT,aAAA,GAAgB,CAAA,CAAE,KAAK,QAAQ,mBAAA;;;;;;;;;;;;;iBAc3B,qBAAA,MAAA,CACd,MAAA,EAAQ,aAAA,EACR,MAAA,EAAQ,YAAA,CAAa,IAAA,IACpB,YAAA,CAAa,IAAA;;UA+BC,iBAAA;;EAEf,OAAA;EACA,MAAM;AAAA;;;;;;;iBASQ,cAAA,WAAyB,iBAAA,CAAA,CACvC,QAAA,WAAmB,CAAA,IACnB,OAAA,kBACC,CAAA"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//#region src/matching/auto-assign.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Auto-assignment of a previewed feed's columns to target fields — the wizard's first guess
|
|
4
|
+
* when an operator uploads a sample file.
|
|
5
|
+
*
|
|
6
|
+
* Kept pure (no React, no server imports) so the matching rules are unit-testable against real
|
|
7
|
+
* supplier headers instead of only being visible by driving the UI.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is not a substring match
|
|
10
|
+
*
|
|
11
|
+
* The previous rule accepted a match when either string contained the other, which is wrong in
|
|
12
|
+
* both directions at once. It fired where it should not — `Barcode` contains `code`, so the
|
|
13
|
+
* barcode column was offered as the REQUIRED part-code field while the actual code column
|
|
14
|
+
* (`ArticleName`, `partno`) matched nothing — and it stayed silent where it mattered, because
|
|
15
|
+
* no real feed names a column after our internal key. Measured against the three ground-truth
|
|
16
|
+
* supplier samples, it assigned two columns correctly, one wrongly, and missed the rest,
|
|
17
|
+
* including every supersession column. A missed price column is loud; a missed supersession
|
|
18
|
+
* column is silent (prices import, the run succeeds, the replacement tables stay empty), which
|
|
19
|
+
* is how the supersession feature shipped switched-off and stayed that way.
|
|
20
|
+
*
|
|
21
|
+
* ## The rules
|
|
22
|
+
*
|
|
23
|
+
* 1. A field's candidate spellings are its `key`, its `label`, and its declared `aliases`, each
|
|
24
|
+
* reduced to lowercase alphanumerics.
|
|
25
|
+
* 2. A column matches a candidate only on a TOKEN BOUNDARY — the header is split on
|
|
26
|
+
* punctuation, whitespace and camelCase humps, and a candidate must equal a contiguous run
|
|
27
|
+
* of those tokens. So `Net Price EUR` and `NetPrice/Discount` still reach `net_price`, while
|
|
28
|
+
* `Barcode` can no longer reach `code`, because `code` is not one of its tokens.
|
|
29
|
+
* 3. Every (column, field) match competes globally and the strongest wins: a whole-header hit
|
|
30
|
+
* beats a partial one, and a longer candidate beats a shorter one. Assignment is greedy over
|
|
31
|
+
* that order, so `Description1` claims `description1` even when a plain `Description` column
|
|
32
|
+
* sits earlier in the file.
|
|
33
|
+
* 4. One target is used at most once. Two columns claiming the same field would be rejected by
|
|
34
|
+
* the mapping validator at save time, so the weaker claimant is left unassigned for the
|
|
35
|
+
* operator to resolve rather than silently producing an unsaveable mapping.
|
|
36
|
+
*/
|
|
37
|
+
/** The subset of a target field this matcher reads. */
|
|
38
|
+
interface AutoAssignField {
|
|
39
|
+
key: string;
|
|
40
|
+
label: string;
|
|
41
|
+
aliases?: readonly string[] | undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Split a header into comparison tokens: on punctuation/whitespace, and on camelCase humps so
|
|
45
|
+
* `ArticleName` → `article` + `name` and `CurrencyCd` → `currency` + `cd`. Truncated to
|
|
46
|
+
* {@link MAX_HEADER_TOKENS}.
|
|
47
|
+
*
|
|
48
|
+
* **Acronym boundaries are deliberately NOT split.** Adding `([A-Z]+)([A-Z][a-z])` looks like a
|
|
49
|
+
* free improvement — it makes `EANCode` read as `ean` + `code` — and it re-opens this module's
|
|
50
|
+
* founding bug: the standalone `code` token then outscores `barcode`'s `ean` alias (4 vs 3), so
|
|
51
|
+
* the BARCODE column gets offered as the required part-code field, silently, exactly as the old
|
|
52
|
+
* substring matcher did. It generalises to any `<Acronym>Code` header, which no alias list can
|
|
53
|
+
* enumerate. Compound spellings that matter are handled as whole-header aliases instead, which
|
|
54
|
+
* beat any partial run.
|
|
55
|
+
*/
|
|
56
|
+
declare function tokenizeHeader(header: string): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Suggest a target field for each column. Returns `column → target key`, omitting any column
|
|
59
|
+
* with no confident match — an absent entry means "the operator decides", never "no mapping
|
|
60
|
+
* needed".
|
|
61
|
+
*/
|
|
62
|
+
declare function suggestTargets(columns: readonly string[], fields: readonly AutoAssignField[]): Map<string, string>;
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/matching/header-snapshot.d.ts
|
|
65
|
+
/**
|
|
66
|
+
* The parsed-sample shape this builder consumes and emits — structurally identical to
|
|
67
|
+
* `FeedPreview` from `preview-feed.ts` and to the wizard's `FeedPreviewResponse`, and
|
|
68
|
+
* declared HERE rather than imported from either so this module keeps zero dependencies.
|
|
69
|
+
* That is what lets one implementation serve the browser wizard and the `lumi` CLI: an
|
|
70
|
+
* import of `preview-feed.js` would pull `csv-parse` and the xlsx reader toward a client
|
|
71
|
+
* bundle, and an import of the wizard's types would point the dependency the wrong way.
|
|
72
|
+
*/
|
|
73
|
+
interface HeaderSample {
|
|
74
|
+
/** Source column names, in first-seen order. */
|
|
75
|
+
columns: string[];
|
|
76
|
+
/** Sample data rows, each `{ column -> cell }`. */
|
|
77
|
+
rows: Array<Record<string, string>>;
|
|
78
|
+
}
|
|
79
|
+
declare const SNAPSHOT_MAX_COLUMNS = 256;
|
|
80
|
+
declare const SNAPSHOT_MAX_COLUMN_CHARS = 256;
|
|
81
|
+
declare const SNAPSHOT_MAX_ROWS = 5;
|
|
82
|
+
declare const SNAPSHOT_MAX_CELL_CHARS = 500;
|
|
83
|
+
declare const SNAPSHOT_MAX_SERIALIZED_CHARS = 100000;
|
|
84
|
+
/**
|
|
85
|
+
* Build the file-less-edit `headerSnapshot` (#667) from the current preview.
|
|
86
|
+
* Rows are capped + cells truncated (they're display samples, not data); a
|
|
87
|
+
* preview that can't fit the caps at all (pathological column count / column
|
|
88
|
+
* names) returns `null` — the mapping saves fine, it just won't support
|
|
89
|
+
* file-less editing.
|
|
90
|
+
*
|
|
91
|
+
* Pure module (no React, no 'use client') so the caps mirror is unit-testable
|
|
92
|
+
* without page infrastructure — same shape as `find-saved-mapping.ts`.
|
|
93
|
+
*/
|
|
94
|
+
declare function toHeaderSnapshot(preview: HeaderSample): HeaderSample | null;
|
|
95
|
+
//#endregion
|
|
96
|
+
export { SNAPSHOT_MAX_ROWS as a, AutoAssignField as c, SNAPSHOT_MAX_COLUMN_CHARS as i, suggestTargets as l, SNAPSHOT_MAX_CELL_CHARS as n, SNAPSHOT_MAX_SERIALIZED_CHARS as o, SNAPSHOT_MAX_COLUMNS as r, toHeaderSnapshot as s, HeaderSample as t, tokenizeHeader as u };
|
|
97
|
+
//# sourceMappingURL=index-Bm69N5DW.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index-Bm69N5DW.d.mts","names":[],"sources":["../src/matching/auto-assign.ts","../src/matching/header-snapshot.ts"],"mappings":";;AAqCA;;;;;;;;AAGS;AAiCT;;;;AAA6C;AAkC7C;;;;;;;;;AAGM;;;;ACtGN;;;;;;;;UD6BiB,eAAA;EACf,GAAA;EACA,KAAA;EACA,OAAA;AAAA;;ACd+B;AACjC;;;;AAAsC;AACtC;;;;AAA8B;AAC9B;iBD4CgB,cAAA,CAAe,MAAc;;;AC5CT;AACpC;;iBD6EgB,cAAA,CACd,OAAA,qBACA,MAAA,WAAiB,eAAA,KAChB,GAAG;;;;AAzEN;;;;;;;UC7BiB,YAAA;EDgCR;EC9BP,OAAA;ED+D4B;EC7D5B,IAAA,EAAM,KAAK,CAAC,MAAA;AAAA;AAAA,cAcD,oBAAA;AAAA,cACA,yBAAA;AAAA,cACA,iBAAA;AAAA,cACA,uBAAA;AAAA,cACA,6BAAA;;;;;;ADgFP;;;;ACtGN;iBAkCgB,gBAAA,CAAiB,OAAA,EAAS,YAAA,GAAe,YAAY"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { A as ErrorSample, B as StreamFeedOptions, C as PriceStats, D as DEFAULT_MAX_PATTERNS, E as parsePriceStats, F as FeedReaderConfig, G as ImportRun, H as countDataRows, I as ReadFeedOptions, K as ImportRunClient, L as feedReaderConfigSchema, M as ErrorTrackerConfig, N as ErrorTrackerSnapshot, O as DEFAULT_MAX_SAMPLES_PER_PATTERN, P as mergeErrorTrackerSnapshots, R as readFeed, S as PriceAnomaly, T as detectPriceAnomaly, U as streamFeed, V as StreamFeedRow, W as IMPORT_RUN_STATUSES, _ as RunImportResult, b as PRICE_ANOMALY_THRESHOLD_MULTIPLIER, c as PairSinkContext, g as RunImportOptions, h as ImportRunProgress, j as ErrorTracker, k as ErrorPattern, m as DEFAULT_BATCH_SIZE, n as EsClientResolver, q as ImportRunStatus, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver, v as runImport, w as PriceStatsCollector, x as PRICE_SAMPLE_CAP, y as EMPTY_PRICE_STATS, z as readPersistedFeedConfig } from "./worker-DH3SN-91.mjs";
|
|
2
1
|
import { a as RowWarning, c as TransformRegistry, i as RowTransform, l as getTransformRegistry, n as RowError, o as TransformContext, r as RowResult, s as TransformName, t as DirectionalPair, u as registerImportTransform } from "./transform-WeiiLdYP.mjs";
|
|
3
|
-
import { a as
|
|
4
|
-
import { n as
|
|
2
|
+
import { a as mappingColumnSchema, n as MappingConfig, o as mappingConfigSchema, t as MappingColumn } from "./engine-DiM4r2Vj.mjs";
|
|
3
|
+
import { A as ErrorSample, B as StreamFeedOptions, C as PriceStats, D as DEFAULT_MAX_PATTERNS, E as parsePriceStats, F as FeedReaderConfig, G as ImportRun, H as countDataRows, I as ReadFeedOptions, K as ImportRunClient, L as feedReaderConfigSchema, M as ErrorTrackerConfig, N as ErrorTrackerSnapshot, O as DEFAULT_MAX_SAMPLES_PER_PATTERN, P as mergeErrorTrackerSnapshots, R as readFeed, S as PriceAnomaly, T as detectPriceAnomaly, U as streamFeed, V as StreamFeedRow, W as IMPORT_RUN_STATUSES, _ as RunImportResult, b as PRICE_ANOMALY_THRESHOLD_MULTIPLIER, c as PairSinkContext, g as RunImportOptions, h as ImportRunProgress, j as ErrorTracker, k as ErrorPattern, m as DEFAULT_BATCH_SIZE, n as EsClientResolver, q as ImportRunStatus, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver, v as runImport, w as PriceStatsCollector, x as PRICE_SAMPLE_CAP, y as EMPTY_PRICE_STATS, z as readPersistedFeedConfig } from "./worker-_MlRH5hd.mjs";
|
|
4
|
+
import { n as CARMAKER_MAPPING } from "./carmaker-feed-_QVQFLUl.mjs";
|
|
5
5
|
import { ToolkitApp } from "@murumets-ee/core";
|
|
6
6
|
import { Behavior } from "@murumets-ee/entity";
|
|
7
7
|
import { Logger as Logger$1 } from "@murumets-ee/logging";
|
|
@@ -277,6 +277,28 @@ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
|
|
|
277
277
|
sheet: import("@murumets-ee/entity").TextField & {
|
|
278
278
|
readonly maxLength: 128;
|
|
279
279
|
};
|
|
280
|
+
/**
|
|
281
|
+
* Field-quoting policy for csv/tsv, forwarded to `csv-parse`'s `quote`.
|
|
282
|
+
*
|
|
283
|
+
* NULL — the default, and today's only behaviour — means quotes are ORDINARY DATA.
|
|
284
|
+
* A single character (in practice `"`) means RFC 4180: that character encloses fields
|
|
285
|
+
* that contain the delimiter, and is doubled to escape itself.
|
|
286
|
+
*
|
|
287
|
+
* **This is per-file evidence, not a house style, and the two settings are not
|
|
288
|
+
* interchangeable.** Measured against the real supplier feeds this pipeline ingests
|
|
289
|
+
* (R027): the Ford CSV has 1,476 rows whose description holds a quoted comma — parsed
|
|
290
|
+
* with quotes as data it dies at row 11,126 of 808,147 with
|
|
291
|
+
* `CSV_RECORD_INCONSISTENT_COLUMNS`. The two tab-delimited feeds carry 2,321 lines with
|
|
292
|
+
* an ODD number of `"` characters (`"O"-RING`, `RACCORDO A "T`, `ECROU A TOLE "C`) —
|
|
293
|
+
* parsed as RFC 4180 they die at row 1,476 with `INVALID_OPENING_QUOTE`. Each feed
|
|
294
|
+
* hard-fails under the other's setting, so this cannot be derived from `format` and
|
|
295
|
+
* cannot be a global default. `lumi imports:analyze` decides it per file by trial parse.
|
|
296
|
+
*
|
|
297
|
+
* Ignored for `xlsx`, which has no text quoting.
|
|
298
|
+
*/
|
|
299
|
+
quote: import("@murumets-ee/entity").TextField & {
|
|
300
|
+
readonly maxLength: 1;
|
|
301
|
+
};
|
|
280
302
|
/**
|
|
281
303
|
* Number format for the `number` per-column transform:
|
|
282
304
|
* `{ decimal: '.'|',', thousands: ''|'.'|','|' ' }`. Opaque json; validated
|
|
@@ -315,7 +337,7 @@ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
|
|
|
315
337
|
readonly default: true;
|
|
316
338
|
readonly indexed: true;
|
|
317
339
|
};
|
|
318
|
-
}, "import_mapping", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").SearchableBehavior, Behavior<Record<never, never>, "imports:import-mapping-unique">, Behavior<Record<never, never>, "imports:import-mapping-validation">]>;
|
|
340
|
+
}, "import_mapping", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").SearchableBehavior, Behavior<Record<never, never>, "imports:import-mapping-unique">, Behavior<Record<never, never>, "imports:import-mapping-quote">, Behavior<Record<never, never>, "imports:import-mapping-validation">]>;
|
|
319
341
|
type ImportMappingClient = AdminClient<typeof ImportMapping.allFields>;
|
|
320
342
|
//#endregion
|
|
321
343
|
//#region src/entities/import-mapping-validation.d.ts
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/admin/cleanup.ts","../src/entities/import-mapping.ts","../src/entities/import-mapping-validation.ts","../src/preview-feed.ts"],"mappings":";;;;;;;;;;;;;AAwQa;AASb;;;;AAAgE;AAWhE;;;;;;;cA9Ma,qBAAA;AAAA,KAOD,kBAAA,WAA6B,qBAAqB;AAAA,UAc7C,0BAAA;EA2Lf;EAzLA,QAAA;EA4LA;EA1LA,IAAA,EAAM,gBAAA;EA2LK;;;;;EArLX,eAAA,GAAkB,gBAAgB;AAAA;AAAA,UAGnB,4BAAA;EACf,OAAA,EAAS,kBAAkB;EAmMZ;EAjMf,MAAA;AAAA;;;;;;;;;;;;;;;iBAyDoB,sBAAA,CACpB,IAAA,EAAM,0BAAA,GACL,OAAA,CAAQ,4BAAA;;AAkJa;AAGxB;;;;;;UA/DiB,cAAA;EACf,QAAA;EACA,aAAA;EACA,UAAA;EACA,WAAA;AAAA;;iBASc,kBAAA,CAAmB,GAAA,YAAe,cAAc;AAAA,UAW/C,gBAAA;EACf,EAAA;EACA,KAAA;EAkDA;EAhDA,QAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,UAAA,EAAY,IAAA;EA8CO;EA5CnB,MAAA,EAAQ,cAAA;EACR,OAAA,EAAS,kBAAA;EACT,MAAA;EA+EM;;;;;;;;;;AACyB;;EAnE/B,eAAA;AAAA;AAAA,UAGe,sBAAA;EClR0B;EDoRzC,UAAA;EClRK;EDoRL,QAAA;EC1PQ;ED4PR,WAAA;EACA,MAAA,EAAQ,MAAA,CAAO,kBAAA;ECvRf;EDyRA,IAAA,EAAM,gBAAA;AAAA;AAAA,UAGS,wBAAA;ECpQf;EDsQA,GAAA,EAAK,UAAA;ECpQG;;;AAAW;EDyQnB,eAAA,GAAkB,gBAAA;EClOwB;EDoO1C,QAAA,YAAoB,eAAA;ECpOiE;EDsOrF,KAAA;ECtO2C;EDwO3C,WAAA;EACA,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;AC/If;;;;;;;AAAA,iBDmLsB,oBAAA,CACpB,IAAA,EAAM,wBAAA,GACL,OAAA,CAAQ,sBAAA;;;UClVM,0BAAA;ED4Ef;EC1EA,GAAA,EAAK,UAAA;ED4EL;;AAAM;AAyDR;;;EC9HE,MAAA;EDgIS;;;;;;;;;ECtHT,QAAA;ED4Me;;;;;;ECrMf,KAAA;EDyMA;ECvMA,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;ADgNf;;;;AAAgE;AAAhE,iBCzKsB,sBAAA,CAAuB,IAAA,EAAM,0BAAA,GAA6B,OAAO;AAAA,UA0FtE,2BAAA;;EAEf,GAAA,EAAK,UAAA;ED+FO;EC7FZ,MAAA;EDgGS;;;;;;;;;;;;ECnFT,aAAA;EDmFA;ECjFA,KAAA;EACA,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;;AD8FE;AAGjB;;;;;;;;;;;;;;;;iBC5EsB,uBAAA,CAAwB,IAAA,EAAM,2BAAA,GAA8B,OAAO;;;;;AD9HrD;AAGpC;;cEzEa,6BAAA;AAAA,KACD,yBAAA,WAAoC,6BAA6B;;cAGhE,sBAAA;AAAA,KACD,mBAAA,WAA8B,sBAAsB;AAAA,
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/admin/cleanup.ts","../src/entities/import-mapping.ts","../src/entities/import-mapping-validation.ts","../src/preview-feed.ts"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{a as e,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./transform-
|
|
1
|
+
import{a as e,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./transform-Cu5YEoOl.mjs";import{a as u,n as d,o as f,r as p,t as m}from"./read-feed-DGjqejw3.mjs";import{n as h,t as g}from"./cleanup-DDljBe0Z.mjs";import{n as _,r as v}from"./engine-B0ExE4jL.mjs";import{a as y,c as b,d as x,f as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k,u as A}from"./runner-C9GIe5Dr.mjs";import{n as j}from"./carmaker-feed-B-QYr3PI.mjs";import{DEFAULT_BOUNDED_CONCURRENCY as M,runBounded as N}from"@murumets-ee/core";const P=[`corrupt`,`clean`,`source_unavailable`,`undetectable`,`error`],F=[`succeeded`],I=5e3;function L(e){return e instanceof Error?e.message:String(e)}const R=new Set([`ENOENT`,`EACCES`,`EPERM`,`EISDIR`,`ENOTDIR`]),z=/FILE_ENDED/;function B(e,t){if(typeof e!=`object`||!e)return!1;if(e instanceof t)return!0;if(`code`in e){let{code:t}=e;if(typeof t==`string`&&R.has(t))return!0}return e instanceof Error&&z.test(e.message)}async function V(e){let{filePath:t,feed:n,resolveFilePath:r}=e;if(n.format!==`xlsx`)return{verdict:`undetectable`,detail:`not an xlsx run (format: ${n.format??`tsv (default)`}) — the defect is exceljs-specific`};if(n.hasHeader===!1)return{verdict:`undetectable`,detail:`run used hasHeader:false — no header row for the guard to test`};let{streamXlsx:i,XlsxEmptyHeaderError:a,XlsxNotAFileError:o}=await import(`./xlsx-reader-_ioPAqm5.mjs`),s;try{s=r?await r(t):{localPath:t}}catch(e){return{verdict:`source_unavailable`,detail:L(e)}}try{let e=!1;for await(let t of i({filePath:s.localPath,...n.headerRowIndex!==void 0&&{headerRowIndex:n.headerRowIndex},...n.sheet!==void 0&&{sheet:n.sheet}})){e=!0;break}return e?{verdict:`clean`}:{verdict:`undetectable`,detail:`file yielded no data rows — the header guard never ran`}}catch(e){return e instanceof a?{verdict:`corrupt`,detail:e.message}:B(e,o)?{verdict:`source_unavailable`,detail:L(e)}:{verdict:`error`,detail:L(e)}}finally{await s.cleanup?.().catch(()=>{})}}function H(e,t){let n=e[t];return typeof n==`number`&&Number.isFinite(n)?n:void 0}function U(e){if(typeof e!=`object`||!e||Array.isArray(e))return{};let t=e;return{rowsRead:H(t,`rowsRead`),rowsSucceeded:H(t,`rowsSucceeded`),rowsFailed:H(t,`rowsFailed`),rowsSkipped:H(t,`rowsSkipped`)}}function W(){return{corrupt:0,clean:0,source_unavailable:0,undetectable:0,error:0}}function G(e){return e.rowsRead===void 0||e.rowsSucceeded===void 0?`unknown`:e.rowsRead>0&&e.rowsSucceeded===0}function K(e){if(e instanceof Date)return e;if(typeof e==`string`){let t=new Date(e);return Number.isNaN(t.getTime())?null:t}return null}async function q(e){let{app:t,resolveFilePath:n,logger:r}=e,i=e.statuses??F;if(i.length===0)throw Error(`auditEmptyHeaderRuns: statuses must name at least one import_run status`);let a=e.limit??200;if(!Number.isInteger(a)||a<1||a>I)throw Error(`auditEmptyHeaderRuns: limit must be an integer in [1, ${I}] (got ${a})`);let o=e.concurrency??M;if(!Number.isInteger(o)||o<1||o>16)throw Error(`auditEmptyHeaderRuns: concurrency must be an integer in [1, 16] (got ${o})`);let s=t.getClient(l),{and:c,desc:u,getTableColumns:d,inArray:f,sql:m}=await import(`drizzle-orm`),h=d(s.getTable()),g=h.status,_=h.params,v=h.createdAt;if(!g||!_||!v)throw Error(`auditEmptyHeaderRuns: import_run schema missing expected columns (status/params/createdAt)`);let y=c(f(g,[...i]),m`${_}->'feed'->>'format' = ${`xlsx`}`),b=await s.count({where:y}),x=await s.findMany({where:y,orderBy:u(v),limit:a}),S=Math.max(0,b-x.length);if(S>0&&r.warn({candidates:b,examined:x.length,notExamined:S,limit:a},`imports audit: capped at the most recent runs — re-run with a higher limit to cover the rest`),x.length===0)return r.info({statuses:i},`imports audit: no xlsx runs to audit`),{candidates:b,examined:0,notExamined:S,counts:W(),runs:[]};r.info({candidates:b,examining:x.length,concurrency:o},`imports audit: re-reading source files for the empty-header defect`);let C=await N(x,async e=>{let t=e.filePath,r=U(e.totals),i={id:e.id,label:e.label,filePath:t,transformName:e.transformName,createdAt:K(e.createdAt),finishedAt:K(e.finishedAt),totals:r,zeroRowsIndexed:G(r)};try{let r=p(e.params);if(!r.success){let e=r.error.issues[0]?.message??`invalid feed reader config`;return{...i,verdict:`error`,detail:`invalid params.feed: ${e}`}}let a=await V({filePath:t,feed:r.data,...n!==void 0&&{resolveFilePath:n}});return{...i,verdict:a.verdict,...a.detail!==void 0&&{detail:a.detail}}}catch(e){return{...i,verdict:`error`,detail:L(e)}}},o),w=W();for(let e of C)w[e.verdict]+=1,e.verdict===`corrupt`&&r.warn({importRunId:e.id,label:e.label,filePath:e.filePath,transformName:e.transformName,createdAt:e.createdAt,totals:e.totals},`imports audit: SILENTLY CORRUPTED run — its source file has no readable header`);let T=C.filter(e=>e.zeroRowsIndexed===!0).length,E=C.filter(e=>e.zeroRowsIndexed===`unknown`).length;return n===void 0&&w.source_unavailable===C.length&&r.warn({examined:C.length},`imports audit: every source was unreadable and no resolveFilePath was given — if filePath holds storage keys rather than local paths, pass storageResolveFilePath`),r.info({candidates:b,examined:C.length,notExamined:S,...w,zeroRowsIndexed:T,totalsUnknown:E},`imports audit: complete`),{candidates:b,examined:C.length,notExamined:S,counts:w,runs:C}}const J=100,Y=20;async function X(e){let{maxRows:t,...n}=e,r=t??20,i=Math.min(100,Math.max(1,Number.isFinite(r)?Math.floor(r):20)),a=[],o=new Map;for await(let{row:e}of d({...n,relaxColumnCount:!0})){for(let t of Object.keys(e))o.set(t,!0);if(a.push(e),a.length>=i)break}return{columns:[...o.keys()],rows:a}}export{j as CARMAKER_MAPPING,k as DEFAULT_BATCH_SIZE,w as DEFAULT_MAX_PATTERNS,A as DEFAULT_MAX_SAMPLES_PER_PATTERN,Y as DEFAULT_PREVIEW_ROWS,P as EMPTY_HEADER_VERDICTS,D as EMPTY_PRICE_STATS,x as ErrorTracker,n as IMPORT_MAPPING_FORMATS,e as IMPORT_MAPPING_TARGET_SCHEMAS,r as IMPORT_RUN_STATUSES,a as ImportMapping,l as ImportRun,J as MAX_PREVIEW_ROWS,C as PRICE_ANOMALY_THRESHOLD_MULTIPLIER,y as PRICE_SAMPLE_CAP,E as PriceStatsCollector,c as TransformRegistry,s as assertValidMapping,q as auditEmptyHeaderRuns,g as cleanupOrphanedImports,h as cleanupProcessedImports,u as countDataRows,O as detectPriceAnomaly,m as feedReaderConfigSchema,i as getTransformRegistry,t as importMappingValidation,V as inspectImportRunSource,_ as mappingColumnSchema,v as mappingConfigSchema,S as mergeErrorTrackerSnapshots,b as parsePriceStats,X as previewFeed,d as readFeed,p as readPersistedFeedConfig,U as readRecordedTotals,o as registerImportTransform,T as runImport,f as streamFeed};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/preview-feed.ts"],"sourcesContent":["/**\n * Retroactive audit for the silent empty-header corruption fixed in #839\n * (issue #842).\n *\n * **What went wrong.** exceljs assigns `this.sharedStrings = []` at the top of\n * `_parseSharedStrings`, before parsing a single entry — and an empty array is\n * truthy, which is what its inline-worksheet guard tests. A shared-strings\n * entry that yields nothing therefore leaves every string cell resolving to\n * `undefined`: the header parses to zero columns, every data row maps to `{}`,\n * and the import reports SUCCESS with every value dropped. #839 made\n * `streamXlsx` refuse that file. It cannot see backwards, and a past occurrence\n * is indistinguishable from success in the `import_run` row alone — both\n * `status: 'succeeded'`, both no fatal error.\n *\n * **What this does.** Re-reads the retained source file of every past xlsx run\n * with the PATCHED reader and classifies each one. `import_run.filePath` is\n * `required: true`, so every run has a source to point at.\n *\n * **What a verdict is worth — read this before acting on a report.**\n *\n * - `corrupt` has no false positives worth worrying about: the guard fires on\n * precisely the condition that produced the empty parse, so a file that\n * throws today would have thrown then. There is no threshold to tune, unlike\n * inferring corruption from suspiciously-low row counts.\n * - `clean` is NOT proof the run was fine. The upstream defect has two causes:\n * a file that genuinely carries an empty/unparseable shared-strings part\n * (deterministic — caught here), and a truncated or starved read at import\n * time (transient — the same file re-reads perfectly today). The file re-read\n * is blind to the second. That is why every audited run also carries\n * {@link AuditedImportRun.zeroRowsIndexed}, which is derived from the\n * recorded totals and needs no file at all: a run that finished `succeeded`\n * having indexed zero rows is the observable signature of the corruption\n * whichever cause produced it. It is corroborating, not conclusive — an\n * operator who uploaded a feed with the wrong column mapping gets the same\n * shape — so it is reported as a separate flag and never folded into the\n * verdict.\n * - `source_unavailable` is the honest answer for a run whose evidence is gone,\n * and there will be some: `cleanupProcessedImports` deletes the source file\n * of any terminal run older than `processedRetentionDays` (plugin default 7),\n * on an hourly schedule. The retained-file window is therefore about a week\n * on a deployment running the queue worker with the default config — run this\n * audit sooner rather than later, and read `zeroRowsIndexed` for the runs\n * whose files have already been reaped.\n *\n * Read-only by construction: it reports, it never re-imports. Deciding what to\n * do about a corrupted run needs the operator.\n *\n * Bounded per CLAUDE.md's fan-out rule — it is `O(runs)` reads against storage,\n * so it caps both the item count AND the peak concurrency, and logs whatever it\n * drops rather than silently truncating. The per-run cost is one file: the\n * resolver downloads the object, and the reader parses to the header of the\n * target sheet plus whatever follows it. Neither cap substitutes for the other,\n * and neither substitutes for running the audit while the answer is still\n * there.\n */\n\nimport { DEFAULT_BOUNDED_CONCURRENCY, runBounded, type ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { ImportRun, type ImportRunStatus } from '../entities/import-run.js'\nimport { type FeedReaderConfig, readPersistedFeedConfig } from '../read-feed.js'\nimport type { FilePathResolver } from '../worker.js'\n\n/**\n * Per-run outcome of the file re-read.\n *\n * - `corrupt` — the patched reader refuses this file's header. The run imported\n * empty rows and reported success.\n * - `clean` — the header re-reads with at least one column today. See the\n * false-negative caveat in the module docblock.\n * - `source_unavailable` — the source file could not be read at all (reaped by\n * the retention sweep, deleted, or storage refused). Undecidable from the\n * file; `zeroRowsIndexed` is the only signal left.\n * - `undetectable` — the run is outside what this detector can decide: a\n * `hasHeader: false` run has no header for the guard to test, a non-xlsx run\n * cannot have the defect, and a file that yields no data rows never reached\n * the guard at all.\n * - `error` — reading failed for some other reason; `detail` carries it.\n */\nexport const EMPTY_HEADER_VERDICTS = [\n 'corrupt',\n 'clean',\n 'source_unavailable',\n 'undetectable',\n 'error',\n] as const\nexport type EmptyHeaderVerdict = (typeof EMPTY_HEADER_VERDICTS)[number]\n\n/** Statuses audited when the caller doesn't name any. */\nconst DEFAULT_STATUSES: readonly ImportRunStatus[] = ['succeeded']\n\n/** Default cap on runs examined per invocation. */\nconst DEFAULT_LIMIT = 200\n\n/** Hard ceiling on the per-invocation cap. Protects storage from a fat-fingered `--limit`. */\nconst MAX_LIMIT = 5000\n\n/** Hard ceiling on peak concurrent reads. Bounds the burst the item cap alone does not. */\nconst MAX_CONCURRENCY = 16\n\nexport interface InspectImportRunSourceArgs {\n /** Local path OR storage key, verbatim from `import_run.filePath`. */\n filePath: string\n /** The run's persisted reader config (`import_run.params.feed`). */\n feed: FeedReaderConfig\n /**\n * Resolves `filePath` to a readable local path, exactly as the worker does.\n * Omit when `filePath` is already a local FS path (the on-disk deployments\n * the worker's no-resolver fallback covers).\n */\n resolveFilePath?: FilePathResolver | undefined\n}\n\nexport interface InspectImportRunSourceResult {\n verdict: EmptyHeaderVerdict\n /** Human-readable reason. Always set for everything except `clean`. */\n detail?: string\n}\n\n/** Best-effort message for an unknown thrown value. */\nfunction describeError(err: unknown): string {\n if (err instanceof Error) return err.message\n return String(err)\n}\n\n/**\n * Filesystem / object-store errors that mean \"the evidence is not readable\",\n * as distinct from \"the file is readable and its header is bad\".\n *\n * ENOENT is the retention sweep having got there first — the expected outcome\n * for anything older than `processedRetentionDays`. The rest are real\n * deployment conditions rather than theory: the imports directory has had uid\n * drift before (#661's `imports-init` chown), and this audit is run from an\n * operator shell, not under the worker's uid — so EACCES against a file the\n * worker wrote is exactly the shape to expect. All of them are \"cannot decide\",\n * never \"corrupt\".\n */\nconst UNREADABLE_SOURCE_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'ENOTDIR'])\n\n/**\n * unzipper's error for a zip that ends mid-entry. A half-written retained\n * upload is evidence we cannot read, not a verdict about the run — checked:\n * truncating a workbook at 95% / 60% / 20% all reject cleanly with this rather\n * than crashing or hanging.\n */\nconst TRUNCATED_ARCHIVE_MESSAGE = /FILE_ENDED/\n\nfunction isUnreadableSource(err: unknown, notAFile: new (...args: never[]) => Error): boolean {\n if (typeof err !== 'object' || err === null) return false\n // A directory (or socket, or fifo) where a workbook should be. Matched by\n // TYPE — the reader exports the class precisely so this is not a regex.\n if (err instanceof notAFile) return true\n if ('code' in err) {\n const { code } = err as { code?: unknown }\n if (typeof code === 'string' && UNREADABLE_SOURCE_CODES.has(code)) return true\n }\n return err instanceof Error && TRUNCATED_ARCHIVE_MESSAGE.test(err.message)\n}\n\n/**\n * Re-read ONE run's source file with the patched reader and classify it.\n *\n * Stops pulling rows once the header has been decided — the guard fires while\n * the header is captured, before any data row is yielded, so the first row\n * settles it.\n *\n * That bounds the rows parsed OF THE TARGET SHEET, and nothing else. The\n * resolver has already downloaded the whole object, and `streamXlsx` parses the\n * worksheets after the target on its way out — it has to, or exceljs strands a\n * temp file and an fd per deferred worksheet. So the per-run cost is one file,\n * plus its trailing sheets; what bounds the SWEEP is the item cap and the\n * concurrency cap in `auditEmptyHeaderRuns`.\n */\nexport async function inspectImportRunSource(\n args: InspectImportRunSourceArgs,\n): Promise<InspectImportRunSourceResult> {\n const { filePath, feed, resolveFilePath } = args\n\n // The defect is exceljs-specific: csv/tsv go through csv-parse, which has no\n // shared-strings table to lose. A run with no persisted `format` defaulted to\n // tab-delimited in the worker, so absence is a real answer, not a gap.\n if (feed.format !== 'xlsx') {\n return {\n verdict: 'undetectable',\n detail: `not an xlsx run (format: ${feed.format ?? 'tsv (default)'}) — the defect is exceljs-specific`,\n }\n }\n // With `hasHeader: false` the reader keys rows positionally and never captures\n // a header, so the guard has nothing to fire on. Say so rather than returning\n // a `clean` that means \"not checked\".\n if (feed.hasHeader === false) {\n return {\n verdict: 'undetectable',\n detail: 'run used hasHeader:false — no header row for the guard to test',\n }\n }\n\n // Dynamic import, matching `read-feed.ts`: exceljs is heavy and Node-only,\n // and this module is reachable from the package's main entry. Bound BEFORE\n // the resolver runs, so a module-resolution failure cannot strand a tmpfile\n // the resolver just downloaded — and outside the try below, because `catch`\n // cannot see a `const` block-scoped to the `try`.\n const { streamXlsx, XlsxEmptyHeaderError, XlsxNotAFileError } = await import('../xlsx-reader.js')\n\n let resolved: { localPath: string; cleanup?: (() => Promise<void>) | undefined }\n try {\n resolved = resolveFilePath ? await resolveFilePath(filePath) : { localPath: filePath }\n } catch (err) {\n // A resolver throw is a storage miss (reaped / deleted / permissions) or a\n // transport failure. `detail` carries which — a 404 and a connection reset\n // read very differently to an operator.\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n\n try {\n let sawRow = false\n for await (const _first of streamXlsx({\n filePath: resolved.localPath,\n ...(feed.headerRowIndex !== undefined && { headerRowIndex: feed.headerRowIndex }),\n ...(feed.sheet !== undefined && { sheet: feed.sheet }),\n })) {\n void _first\n sawRow = true\n break\n }\n if (!sawRow) {\n // The reader completed without yielding, which means the guard never ran:\n // an empty worksheet, or a `headerRowIndex` past the last row. Reporting\n // `clean` here would be the comforting-but-false signal this whole audit\n // exists to avoid — nothing was verified.\n return {\n verdict: 'undetectable',\n detail: 'file yielded no data rows — the header guard never ran',\n }\n }\n return { verdict: 'clean' }\n } catch (err) {\n if (err instanceof XlsxEmptyHeaderError) {\n return { verdict: 'corrupt', detail: err.message }\n }\n if (isUnreadableSource(err, XlsxNotAFileError)) {\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n return { verdict: 'error', detail: describeError(err) }\n } finally {\n await resolved.cleanup?.().catch(() => {\n // Best-effort tmpfile removal, same posture as the worker: a failed\n // cleanup must not turn a read-only audit into a failure. The path is a\n // /tmp copy the OS reclaims anyway.\n })\n }\n}\n\n/**\n * The counters this audit reads out of `import_run.totals`.\n *\n * A LOSSY view, deliberately: `rowsWarned` / `batchesCompleted` /\n * `patchesApplied` / `patchesOrphaned` are dropped rather than passed through,\n * because a report is easier to read for what it needs than for everything the\n * run recorded. Read the `import_run` row itself if you want the rest.\n */\nexport interface RecordedTotals {\n rowsRead?: number | undefined\n rowsSucceeded?: number | undefined\n rowsFailed?: number | undefined\n rowsSkipped?: number | undefined\n}\n\nfunction readCount(source: Record<string, unknown>, key: string): number | undefined {\n const value = source[key]\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined\n}\n\n/** Narrow the opaque `totals` JSONB to the counters this audit reports. */\nexport function readRecordedTotals(raw: unknown): RecordedTotals {\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}\n const source = raw as Record<string, unknown>\n return {\n rowsRead: readCount(source, 'rowsRead'),\n rowsSucceeded: readCount(source, 'rowsSucceeded'),\n rowsFailed: readCount(source, 'rowsFailed'),\n rowsSkipped: readCount(source, 'rowsSkipped'),\n }\n}\n\nexport interface AuditedImportRun {\n id: string\n label: string\n /** Verbatim `import_run.filePath` — the storage key, so an operator can go look. */\n filePath: string\n transformName: string\n createdAt: Date | null\n finishedAt: Date | null\n /** Row counters the run recorded. A corrupted run that recorded a plausible count is the most misleading artefact of all. */\n totals: RecordedTotals\n verdict: EmptyHeaderVerdict\n detail?: string\n /**\n * The run finished having read rows but indexed none. Corroborating evidence\n * that survives the source file being reaped, and the ONLY signal available\n * for the transient variant of the defect — but it is also what a wrong\n * column mapping looks like, so it is never folded into the verdict.\n *\n * THREE-VALUED on purpose. `'unknown'` means the run recorded no usable\n * counters (older rows; any run audited with a non-`succeeded` status, since\n * `markFailed` never writes `totals`). Collapsing that to `false` would render\n * absence of evidence as evidence of absence — in the one signal that is\n * supposed to outlive the file.\n */\n zeroRowsIndexed: boolean | 'unknown'\n}\n\nexport interface EmptyHeaderAuditReport {\n /** Total xlsx runs matching the audited statuses. */\n candidates: number\n /** How many of them this invocation actually read. */\n examined: number\n /** `candidates - examined` — dropped by the cap, newest kept. Never silent: also logged. */\n notExamined: number\n counts: Record<EmptyHeaderVerdict, number>\n /** Every examined run, verdict-annotated, newest first. */\n runs: AuditedImportRun[]\n}\n\nexport interface AuditEmptyHeaderRunsArgs {\n /** Toolkit app — provides the app-bound `AdminClient` for `import_run`. */\n app: ToolkitApp\n /**\n * Same resolver the imports plugin hands the worker (typically\n * `storageResolveFilePath`). Omit only when `filePath` is a local FS path.\n */\n resolveFilePath?: FilePathResolver | undefined\n /** Statuses to audit. Default `['succeeded']` — a failed run announced itself. */\n statuses?: readonly ImportRunStatus[] | undefined\n /** Max runs examined, newest first. Default 200, hard ceiling 5000. */\n limit?: number | undefined\n /** Peak concurrent storage reads. Default 4, hard ceiling 16. */\n concurrency?: number | undefined\n logger: Pick<Logger, 'info' | 'warn'>\n}\n\nfunction emptyCounts(): Record<EmptyHeaderVerdict, number> {\n return { corrupt: 0, clean: 0, source_unavailable: 0, undetectable: 0, error: 0 }\n}\n\n/**\n * Derive the corroborating flag from whatever counters the run recorded.\n * `rowsRead` is what the reader yielded; `rowsSucceeded` is what the cluster\n * acknowledged. Read-but-none-indexed is the shape a `{}`-row stream leaves\n * behind however the transform rejected it (error OR skip), which is why the\n * test is on succeeded rather than failed.\n */\nfunction deriveZeroRowsIndexed(totals: RecordedTotals): boolean | 'unknown' {\n if (totals.rowsRead === undefined || totals.rowsSucceeded === undefined) return 'unknown'\n return totals.rowsRead > 0 && totals.rowsSucceeded === 0\n}\n\n/** Coerce a DTO date field (Date | string | null | undefined) to `Date | null`. */\nfunction toDate(value: unknown): Date | null {\n if (value instanceof Date) return value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n return Number.isNaN(parsed.getTime()) ? null : parsed\n }\n return null\n}\n\n/**\n * Audit past import runs for the silent empty-header corruption.\n *\n * Newest-first, because the answerable window is the recent one — the retention\n * sweep reaps source files ~7 days after a run reaches terminal status by\n * default, so the oldest runs are the ones whose evidence is already gone.\n */\nexport async function auditEmptyHeaderRuns(\n args: AuditEmptyHeaderRunsArgs,\n): Promise<EmptyHeaderAuditReport> {\n const { app, resolveFilePath, logger } = args\n const statuses = args.statuses ?? DEFAULT_STATUSES\n if (statuses.length === 0) {\n throw new Error('auditEmptyHeaderRuns: statuses must name at least one import_run status')\n }\n const limit = args.limit ?? DEFAULT_LIMIT\n if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `auditEmptyHeaderRuns: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n // Concurrency needs its own ceiling, not just `runBounded`'s\n // positive-integer check: `{ limit: 5000, concurrency: 5000 }` type-checks and\n // is a 5000-wide burst at object storage, which is the amplification the item\n // cap alone does not prevent.\n const concurrency = args.concurrency ?? DEFAULT_BOUNDED_CONCURRENCY\n if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {\n throw new Error(\n `auditEmptyHeaderRuns: concurrency must be an integer in [1, ${MAX_CONCURRENCY}] (got ${concurrency})`,\n )\n }\n\n const client = app.getClient(ImportRun)\n\n const { and, desc, getTableColumns, inArray, sql } = await import('drizzle-orm')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n // `getTableColumns` rather than dot-access: the entity table is a generic\n // PgTable, so direct access widens every column to `T | undefined` (same\n // shape the cleanup helpers use).\n const cols = getTableColumns(client.getTable()) as Record<string, AnyPgColumn>\n const statusCol = cols.status\n const paramsCol = cols.params\n const createdAtCol = cols.createdAt\n if (!statusCol || !paramsCol || !createdAtCol) {\n throw new Error(\n 'auditEmptyHeaderRuns: import_run schema missing expected columns (status/params/createdAt)',\n )\n }\n\n // Narrow to xlsx runs IN SQL rather than paging every run and filtering in\n // JS — otherwise the `limit` cap would be spent on tsv rows and a deployment\n // with a long carmaker history would examine nothing. `params` is jsonb, and\n // a JSONB operator inside Drizzle's typed builder is the sanctioned use of\n // the `sql` template (CLAUDE.md \"Database Queries\" rule 5): the column ref is\n // a typed Drizzle object and the compared value is bound as a parameter. The\n // key path mirrors the worker's `params.feed` rehydration exactly.\n const where = and(\n inArray(statusCol, [...statuses]),\n sql`${paramsCol}->'feed'->>'format' = ${'xlsx'}`,\n )\n\n const candidates = await client.count({ where })\n const rows = await client.findMany({ where, orderBy: desc(createdAtCol), limit })\n\n const notExamined = Math.max(0, candidates - rows.length)\n if (notExamined > 0) {\n logger.warn(\n { candidates, examined: rows.length, notExamined, limit },\n 'imports audit: capped at the most recent runs — re-run with a higher limit to cover the rest',\n )\n }\n if (rows.length === 0) {\n logger.info({ statuses }, 'imports audit: no xlsx runs to audit')\n return { candidates, examined: 0, notExamined, counts: emptyCounts(), runs: [] }\n }\n\n logger.info(\n { candidates, examining: rows.length, concurrency },\n 'imports audit: re-reading source files for the empty-header defect',\n )\n\n const audited = await runBounded(\n rows,\n async (row): Promise<AuditedImportRun> => {\n // `filePath` / `label` / `transformName` are `required: true`, so the\n // inferred DTO already types them as `string`. `totals` is `field.json()`.\n const filePath = row.filePath\n const totals = readRecordedTotals(row.totals)\n const base = {\n id: row.id,\n label: row.label,\n filePath,\n transformName: row.transformName,\n createdAt: toDate(row.createdAt),\n finishedAt: toDate(row.finishedAt),\n totals,\n zeroRowsIndexed: deriveZeroRowsIndexed(totals),\n }\n\n // EVERY per-item failure degrades THIS run to `error` and no further.\n // `runBounded` rejects on the first worker rejection, so an unhandled\n // throw here would discard all N results — and the report is the entire\n // product of this function.\n try {\n // Rehydrated through the SAME helper the worker uses, so the audit\n // cannot re-read a run with options that run never used.\n const feedParsed = readPersistedFeedConfig(row.params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n return { ...base, verdict: 'error', detail: `invalid params.feed: ${reason}` }\n }\n\n const result = await inspectImportRunSource({\n filePath,\n feed: feedParsed.data,\n ...(resolveFilePath !== undefined && { resolveFilePath }),\n })\n return {\n ...base,\n verdict: result.verdict,\n ...(result.detail !== undefined && { detail: result.detail }),\n }\n } catch (err) {\n return { ...base, verdict: 'error', detail: describeError(err) }\n }\n },\n concurrency,\n )\n\n const counts = emptyCounts()\n for (const run of audited) {\n counts[run.verdict] += 1\n if (run.verdict === 'corrupt') {\n logger.warn(\n {\n importRunId: run.id,\n label: run.label,\n filePath: run.filePath,\n transformName: run.transformName,\n createdAt: run.createdAt,\n totals: run.totals,\n },\n 'imports audit: SILENTLY CORRUPTED run — its source file has no readable header',\n )\n }\n }\n\n const zeroRowsIndexed = audited.filter((run) => run.zeroRowsIndexed === true).length\n const totalsUnknown = audited.filter((run) => run.zeroRowsIndexed === 'unknown').length\n\n // A run of nothing but `source_unavailable` usually means the audit was\n // pointed at storage keys with no resolver, not that a week of evidence\n // evaporated. Those read identically in the report, so say which is likelier.\n if (resolveFilePath === undefined && counts.source_unavailable === audited.length) {\n logger.warn(\n { examined: audited.length },\n 'imports audit: every source was unreadable and no resolveFilePath was given — ' +\n 'if filePath holds storage keys rather than local paths, pass storageResolveFilePath',\n )\n }\n\n logger.info(\n {\n candidates,\n examined: audited.length,\n notExamined,\n ...counts,\n zeroRowsIndexed,\n totalsUnknown,\n },\n 'imports audit: complete',\n )\n\n return { candidates, examined: audited.length, notExamined, counts, runs: audited }\n}\n","/**\n * `previewFeed` — read the first N data rows of a feed for the PR3 import wizard\n * (commerce-demo phase 02 §4). Reuses {@link readFeed} so the preview is parsed by\n * the EXACT same reader (csv-parse / exceljs), with the same delimiter / header /\n * sheet config, that the run will use — the operator maps against what they'll get,\n * not a re-implemented best-guess parser (R02: drift between preview + run is a\n * mapping foot-gun).\n *\n * Bounded: it consumes only `maxRows` rows then stops iterating, so on a delimited\n * feed it streams a tiny prefix rather than the whole file. (The caller materialises\n * the storage object to a local path first — see the commerce preview route — so for\n * a very large upload the materialise cost dominates; that's a demo-depth trade-off,\n * noted on the route.)\n */\n\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\n\n/** Hard ceiling on preview rows regardless of caller request — bounds work + payload. */\nexport const MAX_PREVIEW_ROWS = 100\n/** Default sample size when the caller doesn't specify. */\nexport const DEFAULT_PREVIEW_ROWS = 20\n\nexport interface PreviewFeedOptions extends ReadFeedOptions {\n /** Rows to sample. Defaults to {@link DEFAULT_PREVIEW_ROWS}; clamped to [1, {@link MAX_PREVIEW_ROWS}]. */\n maxRows?: number\n}\n\nexport interface FeedPreview {\n /**\n * Source column names in first-seen order. With a header these are the (trimmed)\n * header names; headerless feeds yield positional `\"0\"`, `\"1\"`, … keys. The union\n * across sampled rows covers feeds whose first row omits trailing empty cells.\n */\n columns: string[]\n /** Up to `maxRows` sampled data rows, each a `{ column → cell }` map (cells are strings). */\n rows: Array<Record<string, string>>\n}\n\n/**\n * Read up to `maxRows` data rows from a feed file and return the detected columns +\n * sample rows. Throws whatever the underlying reader throws (bad sheet index,\n * duplicate header, unreadable file) — the caller maps that to a 4xx.\n */\nexport async function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview> {\n const { maxRows, ...feedOptions } = options\n // Coerce to a finite integer BEFORE clamping: `previewFeed` is public API, so a\n // caller passing `NaN`/`Infinity`/fractional must not slip through. `NaN` in\n // particular would make `rows.length >= limit` always false → read the whole file.\n const requested = maxRows ?? DEFAULT_PREVIEW_ROWS\n const safeRows = Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_PREVIEW_ROWS\n const limit = Math.min(MAX_PREVIEW_ROWS, Math.max(1, safeRows))\n\n const rows: Array<Record<string, string>> = []\n // First-seen-order column set: a Map preserves insertion order and dedupes.\n const columns = new Map<string, true>()\n\n // Preview is EXPLORATORY — force lenient column counts (csv/tsv). With the wrong\n // delimiter a feed parses as a single odd column, OR a row's field count differs\n // from the header; strict parsing would abort the whole preview with a cryptic\n // \"Invalid Record Length\" instead of letting the operator SEE the bad parse and\n // fix the delimiter/format, then re-preview. The actual run keeps its own\n // (stricter) settings.\n for await (const { row } of readFeed({ ...feedOptions, relaxColumnCount: true })) {\n for (const key of Object.keys(row)) columns.set(key, true)\n rows.push(row)\n if (rows.length >= limit) break\n }\n\n return { columns: [...columns.keys()], rows }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/preview-feed.ts"],"sourcesContent":["/**\n * Retroactive audit for the silent empty-header corruption fixed in #839\n * (issue #842).\n *\n * **What went wrong.** exceljs assigns `this.sharedStrings = []` at the top of\n * `_parseSharedStrings`, before parsing a single entry — and an empty array is\n * truthy, which is what its inline-worksheet guard tests. A shared-strings\n * entry that yields nothing therefore leaves every string cell resolving to\n * `undefined`: the header parses to zero columns, every data row maps to `{}`,\n * and the import reports SUCCESS with every value dropped. #839 made\n * `streamXlsx` refuse that file. It cannot see backwards, and a past occurrence\n * is indistinguishable from success in the `import_run` row alone — both\n * `status: 'succeeded'`, both no fatal error.\n *\n * **What this does.** Re-reads the retained source file of every past xlsx run\n * with the PATCHED reader and classifies each one. `import_run.filePath` is\n * `required: true`, so every run has a source to point at.\n *\n * **What a verdict is worth — read this before acting on a report.**\n *\n * - `corrupt` has no false positives worth worrying about: the guard fires on\n * precisely the condition that produced the empty parse, so a file that\n * throws today would have thrown then. There is no threshold to tune, unlike\n * inferring corruption from suspiciously-low row counts.\n * - `clean` is NOT proof the run was fine. The upstream defect has two causes:\n * a file that genuinely carries an empty/unparseable shared-strings part\n * (deterministic — caught here), and a truncated or starved read at import\n * time (transient — the same file re-reads perfectly today). The file re-read\n * is blind to the second. That is why every audited run also carries\n * {@link AuditedImportRun.zeroRowsIndexed}, which is derived from the\n * recorded totals and needs no file at all: a run that finished `succeeded`\n * having indexed zero rows is the observable signature of the corruption\n * whichever cause produced it. It is corroborating, not conclusive — an\n * operator who uploaded a feed with the wrong column mapping gets the same\n * shape — so it is reported as a separate flag and never folded into the\n * verdict.\n * - `source_unavailable` is the honest answer for a run whose evidence is gone,\n * and there will be some: `cleanupProcessedImports` deletes the source file\n * of any terminal run older than `processedRetentionDays` (plugin default 7),\n * on an hourly schedule. The retained-file window is therefore about a week\n * on a deployment running the queue worker with the default config — run this\n * audit sooner rather than later, and read `zeroRowsIndexed` for the runs\n * whose files have already been reaped.\n *\n * Read-only by construction: it reports, it never re-imports. Deciding what to\n * do about a corrupted run needs the operator.\n *\n * Bounded per CLAUDE.md's fan-out rule — it is `O(runs)` reads against storage,\n * so it caps both the item count AND the peak concurrency, and logs whatever it\n * drops rather than silently truncating. The per-run cost is one file: the\n * resolver downloads the object, and the reader parses to the header of the\n * target sheet plus whatever follows it. Neither cap substitutes for the other,\n * and neither substitutes for running the audit while the answer is still\n * there.\n */\n\nimport { DEFAULT_BOUNDED_CONCURRENCY, runBounded, type ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { ImportRun, type ImportRunStatus } from '../entities/import-run.js'\nimport { type FeedReaderConfig, readPersistedFeedConfig } from '../read-feed.js'\nimport type { FilePathResolver } from '../worker.js'\n\n/**\n * Per-run outcome of the file re-read.\n *\n * - `corrupt` — the patched reader refuses this file's header. The run imported\n * empty rows and reported success.\n * - `clean` — the header re-reads with at least one column today. See the\n * false-negative caveat in the module docblock.\n * - `source_unavailable` — the source file could not be read at all (reaped by\n * the retention sweep, deleted, or storage refused). Undecidable from the\n * file; `zeroRowsIndexed` is the only signal left.\n * - `undetectable` — the run is outside what this detector can decide: a\n * `hasHeader: false` run has no header for the guard to test, a non-xlsx run\n * cannot have the defect, and a file that yields no data rows never reached\n * the guard at all.\n * - `error` — reading failed for some other reason; `detail` carries it.\n */\nexport const EMPTY_HEADER_VERDICTS = [\n 'corrupt',\n 'clean',\n 'source_unavailable',\n 'undetectable',\n 'error',\n] as const\nexport type EmptyHeaderVerdict = (typeof EMPTY_HEADER_VERDICTS)[number]\n\n/** Statuses audited when the caller doesn't name any. */\nconst DEFAULT_STATUSES: readonly ImportRunStatus[] = ['succeeded']\n\n/** Default cap on runs examined per invocation. */\nconst DEFAULT_LIMIT = 200\n\n/** Hard ceiling on the per-invocation cap. Protects storage from a fat-fingered `--limit`. */\nconst MAX_LIMIT = 5000\n\n/** Hard ceiling on peak concurrent reads. Bounds the burst the item cap alone does not. */\nconst MAX_CONCURRENCY = 16\n\nexport interface InspectImportRunSourceArgs {\n /** Local path OR storage key, verbatim from `import_run.filePath`. */\n filePath: string\n /** The run's persisted reader config (`import_run.params.feed`). */\n feed: FeedReaderConfig\n /**\n * Resolves `filePath` to a readable local path, exactly as the worker does.\n * Omit when `filePath` is already a local FS path (the on-disk deployments\n * the worker's no-resolver fallback covers).\n */\n resolveFilePath?: FilePathResolver | undefined\n}\n\nexport interface InspectImportRunSourceResult {\n verdict: EmptyHeaderVerdict\n /** Human-readable reason. Always set for everything except `clean`. */\n detail?: string\n}\n\n/** Best-effort message for an unknown thrown value. */\nfunction describeError(err: unknown): string {\n if (err instanceof Error) return err.message\n return String(err)\n}\n\n/**\n * Filesystem / object-store errors that mean \"the evidence is not readable\",\n * as distinct from \"the file is readable and its header is bad\".\n *\n * ENOENT is the retention sweep having got there first — the expected outcome\n * for anything older than `processedRetentionDays`. The rest are real\n * deployment conditions rather than theory: the imports directory has had uid\n * drift before (#661's `imports-init` chown), and this audit is run from an\n * operator shell, not under the worker's uid — so EACCES against a file the\n * worker wrote is exactly the shape to expect. All of them are \"cannot decide\",\n * never \"corrupt\".\n */\nconst UNREADABLE_SOURCE_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'ENOTDIR'])\n\n/**\n * unzipper's error for a zip that ends mid-entry. A half-written retained\n * upload is evidence we cannot read, not a verdict about the run — checked:\n * truncating a workbook at 95% / 60% / 20% all reject cleanly with this rather\n * than crashing or hanging.\n */\nconst TRUNCATED_ARCHIVE_MESSAGE = /FILE_ENDED/\n\nfunction isUnreadableSource(err: unknown, notAFile: new (...args: never[]) => Error): boolean {\n if (typeof err !== 'object' || err === null) return false\n // A directory (or socket, or fifo) where a workbook should be. Matched by\n // TYPE — the reader exports the class precisely so this is not a regex.\n if (err instanceof notAFile) return true\n if ('code' in err) {\n const { code } = err as { code?: unknown }\n if (typeof code === 'string' && UNREADABLE_SOURCE_CODES.has(code)) return true\n }\n return err instanceof Error && TRUNCATED_ARCHIVE_MESSAGE.test(err.message)\n}\n\n/**\n * Re-read ONE run's source file with the patched reader and classify it.\n *\n * Stops pulling rows once the header has been decided — the guard fires while\n * the header is captured, before any data row is yielded, so the first row\n * settles it.\n *\n * That bounds the rows parsed OF THE TARGET SHEET, and nothing else. The\n * resolver has already downloaded the whole object, and `streamXlsx` parses the\n * worksheets after the target on its way out — it has to, or exceljs strands a\n * temp file and an fd per deferred worksheet. So the per-run cost is one file,\n * plus its trailing sheets; what bounds the SWEEP is the item cap and the\n * concurrency cap in `auditEmptyHeaderRuns`.\n */\nexport async function inspectImportRunSource(\n args: InspectImportRunSourceArgs,\n): Promise<InspectImportRunSourceResult> {\n const { filePath, feed, resolveFilePath } = args\n\n // The defect is exceljs-specific: csv/tsv go through csv-parse, which has no\n // shared-strings table to lose. A run with no persisted `format` defaulted to\n // tab-delimited in the worker, so absence is a real answer, not a gap.\n if (feed.format !== 'xlsx') {\n return {\n verdict: 'undetectable',\n detail: `not an xlsx run (format: ${feed.format ?? 'tsv (default)'}) — the defect is exceljs-specific`,\n }\n }\n // With `hasHeader: false` the reader keys rows positionally and never captures\n // a header, so the guard has nothing to fire on. Say so rather than returning\n // a `clean` that means \"not checked\".\n if (feed.hasHeader === false) {\n return {\n verdict: 'undetectable',\n detail: 'run used hasHeader:false — no header row for the guard to test',\n }\n }\n\n // Dynamic import, matching `read-feed.ts`: exceljs is heavy and Node-only,\n // and this module is reachable from the package's main entry. Bound BEFORE\n // the resolver runs, so a module-resolution failure cannot strand a tmpfile\n // the resolver just downloaded — and outside the try below, because `catch`\n // cannot see a `const` block-scoped to the `try`.\n const { streamXlsx, XlsxEmptyHeaderError, XlsxNotAFileError } = await import('../xlsx-reader.js')\n\n let resolved: { localPath: string; cleanup?: (() => Promise<void>) | undefined }\n try {\n resolved = resolveFilePath ? await resolveFilePath(filePath) : { localPath: filePath }\n } catch (err) {\n // A resolver throw is a storage miss (reaped / deleted / permissions) or a\n // transport failure. `detail` carries which — a 404 and a connection reset\n // read very differently to an operator.\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n\n try {\n let sawRow = false\n for await (const _first of streamXlsx({\n filePath: resolved.localPath,\n ...(feed.headerRowIndex !== undefined && { headerRowIndex: feed.headerRowIndex }),\n ...(feed.sheet !== undefined && { sheet: feed.sheet }),\n })) {\n void _first\n sawRow = true\n break\n }\n if (!sawRow) {\n // The reader completed without yielding, which means the guard never ran:\n // an empty worksheet, or a `headerRowIndex` past the last row. Reporting\n // `clean` here would be the comforting-but-false signal this whole audit\n // exists to avoid — nothing was verified.\n return {\n verdict: 'undetectable',\n detail: 'file yielded no data rows — the header guard never ran',\n }\n }\n return { verdict: 'clean' }\n } catch (err) {\n if (err instanceof XlsxEmptyHeaderError) {\n return { verdict: 'corrupt', detail: err.message }\n }\n if (isUnreadableSource(err, XlsxNotAFileError)) {\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n return { verdict: 'error', detail: describeError(err) }\n } finally {\n await resolved.cleanup?.().catch(() => {\n // Best-effort tmpfile removal, same posture as the worker: a failed\n // cleanup must not turn a read-only audit into a failure. The path is a\n // /tmp copy the OS reclaims anyway.\n })\n }\n}\n\n/**\n * The counters this audit reads out of `import_run.totals`.\n *\n * A LOSSY view, deliberately: `rowsWarned` / `batchesCompleted` /\n * `patchesApplied` / `patchesOrphaned` are dropped rather than passed through,\n * because a report is easier to read for what it needs than for everything the\n * run recorded. Read the `import_run` row itself if you want the rest.\n */\nexport interface RecordedTotals {\n rowsRead?: number | undefined\n rowsSucceeded?: number | undefined\n rowsFailed?: number | undefined\n rowsSkipped?: number | undefined\n}\n\nfunction readCount(source: Record<string, unknown>, key: string): number | undefined {\n const value = source[key]\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined\n}\n\n/** Narrow the opaque `totals` JSONB to the counters this audit reports. */\nexport function readRecordedTotals(raw: unknown): RecordedTotals {\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}\n const source = raw as Record<string, unknown>\n return {\n rowsRead: readCount(source, 'rowsRead'),\n rowsSucceeded: readCount(source, 'rowsSucceeded'),\n rowsFailed: readCount(source, 'rowsFailed'),\n rowsSkipped: readCount(source, 'rowsSkipped'),\n }\n}\n\nexport interface AuditedImportRun {\n id: string\n label: string\n /** Verbatim `import_run.filePath` — the storage key, so an operator can go look. */\n filePath: string\n transformName: string\n createdAt: Date | null\n finishedAt: Date | null\n /** Row counters the run recorded. A corrupted run that recorded a plausible count is the most misleading artefact of all. */\n totals: RecordedTotals\n verdict: EmptyHeaderVerdict\n detail?: string\n /**\n * The run finished having read rows but indexed none. Corroborating evidence\n * that survives the source file being reaped, and the ONLY signal available\n * for the transient variant of the defect — but it is also what a wrong\n * column mapping looks like, so it is never folded into the verdict.\n *\n * THREE-VALUED on purpose. `'unknown'` means the run recorded no usable\n * counters (older rows; any run audited with a non-`succeeded` status, since\n * `markFailed` never writes `totals`). Collapsing that to `false` would render\n * absence of evidence as evidence of absence — in the one signal that is\n * supposed to outlive the file.\n */\n zeroRowsIndexed: boolean | 'unknown'\n}\n\nexport interface EmptyHeaderAuditReport {\n /** Total xlsx runs matching the audited statuses. */\n candidates: number\n /** How many of them this invocation actually read. */\n examined: number\n /** `candidates - examined` — dropped by the cap, newest kept. Never silent: also logged. */\n notExamined: number\n counts: Record<EmptyHeaderVerdict, number>\n /** Every examined run, verdict-annotated, newest first. */\n runs: AuditedImportRun[]\n}\n\nexport interface AuditEmptyHeaderRunsArgs {\n /** Toolkit app — provides the app-bound `AdminClient` for `import_run`. */\n app: ToolkitApp\n /**\n * Same resolver the imports plugin hands the worker (typically\n * `storageResolveFilePath`). Omit only when `filePath` is a local FS path.\n */\n resolveFilePath?: FilePathResolver | undefined\n /** Statuses to audit. Default `['succeeded']` — a failed run announced itself. */\n statuses?: readonly ImportRunStatus[] | undefined\n /** Max runs examined, newest first. Default 200, hard ceiling 5000. */\n limit?: number | undefined\n /** Peak concurrent storage reads. Default 4, hard ceiling 16. */\n concurrency?: number | undefined\n logger: Pick<Logger, 'info' | 'warn'>\n}\n\nfunction emptyCounts(): Record<EmptyHeaderVerdict, number> {\n return { corrupt: 0, clean: 0, source_unavailable: 0, undetectable: 0, error: 0 }\n}\n\n/**\n * Derive the corroborating flag from whatever counters the run recorded.\n * `rowsRead` is what the reader yielded; `rowsSucceeded` is what the cluster\n * acknowledged. Read-but-none-indexed is the shape a `{}`-row stream leaves\n * behind however the transform rejected it (error OR skip), which is why the\n * test is on succeeded rather than failed.\n */\nfunction deriveZeroRowsIndexed(totals: RecordedTotals): boolean | 'unknown' {\n if (totals.rowsRead === undefined || totals.rowsSucceeded === undefined) return 'unknown'\n return totals.rowsRead > 0 && totals.rowsSucceeded === 0\n}\n\n/** Coerce a DTO date field (Date | string | null | undefined) to `Date | null`. */\nfunction toDate(value: unknown): Date | null {\n if (value instanceof Date) return value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n return Number.isNaN(parsed.getTime()) ? null : parsed\n }\n return null\n}\n\n/**\n * Audit past import runs for the silent empty-header corruption.\n *\n * Newest-first, because the answerable window is the recent one — the retention\n * sweep reaps source files ~7 days after a run reaches terminal status by\n * default, so the oldest runs are the ones whose evidence is already gone.\n */\nexport async function auditEmptyHeaderRuns(\n args: AuditEmptyHeaderRunsArgs,\n): Promise<EmptyHeaderAuditReport> {\n const { app, resolveFilePath, logger } = args\n const statuses = args.statuses ?? DEFAULT_STATUSES\n if (statuses.length === 0) {\n throw new Error('auditEmptyHeaderRuns: statuses must name at least one import_run status')\n }\n const limit = args.limit ?? DEFAULT_LIMIT\n if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `auditEmptyHeaderRuns: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n // Concurrency needs its own ceiling, not just `runBounded`'s\n // positive-integer check: `{ limit: 5000, concurrency: 5000 }` type-checks and\n // is a 5000-wide burst at object storage, which is the amplification the item\n // cap alone does not prevent.\n const concurrency = args.concurrency ?? DEFAULT_BOUNDED_CONCURRENCY\n if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {\n throw new Error(\n `auditEmptyHeaderRuns: concurrency must be an integer in [1, ${MAX_CONCURRENCY}] (got ${concurrency})`,\n )\n }\n\n const client = app.getClient(ImportRun)\n\n const { and, desc, getTableColumns, inArray, sql } = await import('drizzle-orm')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n // `getTableColumns` rather than dot-access: the entity table is a generic\n // PgTable, so direct access widens every column to `T | undefined` (same\n // shape the cleanup helpers use).\n const cols = getTableColumns(client.getTable()) as Record<string, AnyPgColumn>\n const statusCol = cols.status\n const paramsCol = cols.params\n const createdAtCol = cols.createdAt\n if (!statusCol || !paramsCol || !createdAtCol) {\n throw new Error(\n 'auditEmptyHeaderRuns: import_run schema missing expected columns (status/params/createdAt)',\n )\n }\n\n // Narrow to xlsx runs IN SQL rather than paging every run and filtering in\n // JS — otherwise the `limit` cap would be spent on tsv rows and a deployment\n // with a long carmaker history would examine nothing. `params` is jsonb, and\n // a JSONB operator inside Drizzle's typed builder is the sanctioned use of\n // the `sql` template (CLAUDE.md \"Database Queries\" rule 5): the column ref is\n // a typed Drizzle object and the compared value is bound as a parameter. The\n // key path mirrors the worker's `params.feed` rehydration exactly.\n const where = and(\n inArray(statusCol, [...statuses]),\n sql`${paramsCol}->'feed'->>'format' = ${'xlsx'}`,\n )\n\n const candidates = await client.count({ where })\n const rows = await client.findMany({ where, orderBy: desc(createdAtCol), limit })\n\n const notExamined = Math.max(0, candidates - rows.length)\n if (notExamined > 0) {\n logger.warn(\n { candidates, examined: rows.length, notExamined, limit },\n 'imports audit: capped at the most recent runs — re-run with a higher limit to cover the rest',\n )\n }\n if (rows.length === 0) {\n logger.info({ statuses }, 'imports audit: no xlsx runs to audit')\n return { candidates, examined: 0, notExamined, counts: emptyCounts(), runs: [] }\n }\n\n logger.info(\n { candidates, examining: rows.length, concurrency },\n 'imports audit: re-reading source files for the empty-header defect',\n )\n\n const audited = await runBounded(\n rows,\n async (row): Promise<AuditedImportRun> => {\n // `filePath` / `label` / `transformName` are `required: true`, so the\n // inferred DTO already types them as `string`. `totals` is `field.json()`.\n const filePath = row.filePath\n const totals = readRecordedTotals(row.totals)\n const base = {\n id: row.id,\n label: row.label,\n filePath,\n transformName: row.transformName,\n createdAt: toDate(row.createdAt),\n finishedAt: toDate(row.finishedAt),\n totals,\n zeroRowsIndexed: deriveZeroRowsIndexed(totals),\n }\n\n // EVERY per-item failure degrades THIS run to `error` and no further.\n // `runBounded` rejects on the first worker rejection, so an unhandled\n // throw here would discard all N results — and the report is the entire\n // product of this function.\n try {\n // Rehydrated through the SAME helper the worker uses, so the audit\n // cannot re-read a run with options that run never used.\n const feedParsed = readPersistedFeedConfig(row.params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n return { ...base, verdict: 'error', detail: `invalid params.feed: ${reason}` }\n }\n\n const result = await inspectImportRunSource({\n filePath,\n feed: feedParsed.data,\n ...(resolveFilePath !== undefined && { resolveFilePath }),\n })\n return {\n ...base,\n verdict: result.verdict,\n ...(result.detail !== undefined && { detail: result.detail }),\n }\n } catch (err) {\n return { ...base, verdict: 'error', detail: describeError(err) }\n }\n },\n concurrency,\n )\n\n const counts = emptyCounts()\n for (const run of audited) {\n counts[run.verdict] += 1\n if (run.verdict === 'corrupt') {\n logger.warn(\n {\n importRunId: run.id,\n label: run.label,\n filePath: run.filePath,\n transformName: run.transformName,\n createdAt: run.createdAt,\n totals: run.totals,\n },\n 'imports audit: SILENTLY CORRUPTED run — its source file has no readable header',\n )\n }\n }\n\n const zeroRowsIndexed = audited.filter((run) => run.zeroRowsIndexed === true).length\n const totalsUnknown = audited.filter((run) => run.zeroRowsIndexed === 'unknown').length\n\n // A run of nothing but `source_unavailable` usually means the audit was\n // pointed at storage keys with no resolver, not that a week of evidence\n // evaporated. Those read identically in the report, so say which is likelier.\n if (resolveFilePath === undefined && counts.source_unavailable === audited.length) {\n logger.warn(\n { examined: audited.length },\n 'imports audit: every source was unreadable and no resolveFilePath was given — ' +\n 'if filePath holds storage keys rather than local paths, pass storageResolveFilePath',\n )\n }\n\n logger.info(\n {\n candidates,\n examined: audited.length,\n notExamined,\n ...counts,\n zeroRowsIndexed,\n totalsUnknown,\n },\n 'imports audit: complete',\n )\n\n return { candidates, examined: audited.length, notExamined, counts, runs: audited }\n}\n","/**\n * `previewFeed` — read the first N data rows of a feed for the PR3 import wizard\n * (commerce-demo phase 02 §4). Reuses {@link readFeed} so the preview is parsed by\n * the EXACT same reader (csv-parse / exceljs), with the same delimiter / header /\n * sheet config, that the run will use — the operator maps against what they'll get,\n * not a re-implemented best-guess parser (R02: drift between preview + run is a\n * mapping foot-gun).\n *\n * Bounded: it consumes only `maxRows` rows then stops iterating, so on a delimited\n * feed it streams a tiny prefix rather than the whole file. (The caller materialises\n * the storage object to a local path first — see the commerce preview route — so for\n * a very large upload the materialise cost dominates; that's a demo-depth trade-off,\n * noted on the route.)\n */\n\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\n\n/** Hard ceiling on preview rows regardless of caller request — bounds work + payload. */\nexport const MAX_PREVIEW_ROWS = 100\n/** Default sample size when the caller doesn't specify. */\nexport const DEFAULT_PREVIEW_ROWS = 20\n\nexport interface PreviewFeedOptions extends ReadFeedOptions {\n /** Rows to sample. Defaults to {@link DEFAULT_PREVIEW_ROWS}; clamped to [1, {@link MAX_PREVIEW_ROWS}]. */\n maxRows?: number\n}\n\nexport interface FeedPreview {\n /**\n * Source column names in first-seen order. With a header these are the (trimmed)\n * header names; headerless feeds yield positional `\"0\"`, `\"1\"`, … keys. The union\n * across sampled rows covers feeds whose first row omits trailing empty cells.\n */\n columns: string[]\n /** Up to `maxRows` sampled data rows, each a `{ column → cell }` map (cells are strings). */\n rows: Array<Record<string, string>>\n}\n\n/**\n * Read up to `maxRows` data rows from a feed file and return the detected columns +\n * sample rows. Throws whatever the underlying reader throws (bad sheet index,\n * duplicate header, unreadable file) — the caller maps that to a 4xx.\n */\nexport async function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview> {\n const { maxRows, ...feedOptions } = options\n // Coerce to a finite integer BEFORE clamping: `previewFeed` is public API, so a\n // caller passing `NaN`/`Infinity`/fractional must not slip through. `NaN` in\n // particular would make `rows.length >= limit` always false → read the whole file.\n const requested = maxRows ?? DEFAULT_PREVIEW_ROWS\n const safeRows = Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_PREVIEW_ROWS\n const limit = Math.min(MAX_PREVIEW_ROWS, Math.max(1, safeRows))\n\n const rows: Array<Record<string, string>> = []\n // First-seen-order column set: a Map preserves insertion order and dedupes.\n const columns = new Map<string, true>()\n\n // Preview is EXPLORATORY — force lenient column counts (csv/tsv). With the wrong\n // delimiter a feed parses as a single odd column, OR a row's field count differs\n // from the header; strict parsing would abort the whole preview with a cryptic\n // \"Invalid Record Length\" instead of letting the operator SEE the bad parse and\n // fix the delimiter/format, then re-preview. The actual run keeps its own\n // (stricter) settings.\n for await (const { row } of readFeed({ ...feedOptions, relaxColumnCount: true })) {\n for (const key of Object.keys(row)) columns.set(key, true)\n rows.push(row)\n if (rows.length >= limit) break\n }\n\n return { columns: [...columns.keys()], rows }\n}\n"],"mappings":"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"}
|
package/dist/mapping/index.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { i as RowTransform } from "../transform-WeiiLdYP.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { C as columnTransformSchema, D as toMinorUnits, E as parseNumber, S as applyColumnTransforms, T as parseEuropeanNumber, _ as COLUMN_TRANSFORM_OPS, a as mappingColumnSchema, b as ColumnTransformOp, c as resolveMapping, d as NumberOutcome, f as TargetField, g as textOrNull, h as rowError, i as buildMappingTransform, l as BuildRowContext, m as numberCell, n as MappingConfig, o as mappingConfigSchema, p as TargetSchema, r as ResolvableMapping, s as numberFormatSchema, t as MappingColumn, u as MappedRow, v as CellValue, w as NumberFormat, x as ColumnTransformOpDescriptor, y as ColumnTransform } from "../engine-DiM4r2Vj.mjs";
|
|
3
|
+
import { i as partsSchema, n as normalizeCode, r as partsParamsSchema, t as PartsParams } from "../parts-schema-DOWhBJYh.mjs";
|
|
3
4
|
|
|
4
5
|
//#region src/mapping/discount-table-schema.d.ts
|
|
5
6
|
/** One parsed discount-table row: a group token + the purchase discount % off list. */
|