@murumets-ee/imports 0.110.0 → 0.112.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.
Files changed (63) hide show
  1. package/dist/analyze/index.d.mts +0 -1
  2. package/dist/analyze/index.mjs +0 -1
  3. package/dist/carmaker-feed-B-QYr3PI.mjs +0 -1
  4. package/dist/carmaker-feed-CWrZLYUt.d.mts +0 -1
  5. package/dist/cleanup-DDljBe0Z.mjs +0 -1
  6. package/dist/engine-B0ExE4jL.mjs +0 -1
  7. package/dist/engine-tkbUXKxB.d.mts +0 -1
  8. package/dist/index-Bm69N5DW.d.mts +0 -1
  9. package/dist/index.d.mts +0 -1
  10. package/dist/index.mjs +0 -1
  11. package/dist/mapping/index.d.mts +0 -1
  12. package/dist/mapping-transform-CO4MNhMe.mjs +0 -1
  13. package/dist/matching-Sr0z4HvN.mjs +0 -1
  14. package/dist/parts-schema-M1AtjNJt.d.mts +0 -1
  15. package/dist/parts-schema-QqLci_Pl.mjs +0 -1
  16. package/dist/plugin.d.mts +0 -1
  17. package/dist/plugin.mjs +0 -1
  18. package/dist/publish-DCXIPwTE.mjs +0 -1
  19. package/dist/read-feed-DsLgRDNR.mjs +0 -1
  20. package/dist/realtime/index.d.mts +0 -1
  21. package/dist/runner-BnFvID1z.mjs +0 -1
  22. package/dist/schema-registry-DB5aIZMN.mjs +0 -1
  23. package/dist/storage-resolver.d.mts +0 -1
  24. package/dist/storage-resolver.mjs +0 -1
  25. package/dist/storage.d.mts +0 -1
  26. package/dist/storage.mjs +0 -1
  27. package/dist/transform-Cu5YEoOl.mjs +0 -1
  28. package/dist/transform-WeiiLdYP.d.mts +0 -1
  29. package/dist/worker-CRH_PjKO.d.mts +0 -1
  30. package/dist/worker.mjs +0 -1
  31. package/dist/xlsx-reader-D9Ntxm29.mjs +0 -1
  32. package/package.json +8 -8
  33. package/dist/analyze/index.d.mts.map +0 -1
  34. package/dist/analyze/index.mjs.map +0 -1
  35. package/dist/carmaker-feed-B-QYr3PI.mjs.map +0 -1
  36. package/dist/carmaker-feed-CWrZLYUt.d.mts.map +0 -1
  37. package/dist/cleanup-DDljBe0Z.mjs.map +0 -1
  38. package/dist/engine-B0ExE4jL.mjs.map +0 -1
  39. package/dist/engine-tkbUXKxB.d.mts.map +0 -1
  40. package/dist/index-Bm69N5DW.d.mts.map +0 -1
  41. package/dist/index.d.mts.map +0 -1
  42. package/dist/index.mjs.map +0 -1
  43. package/dist/mapping/index.d.mts.map +0 -1
  44. package/dist/mapping-transform-CO4MNhMe.mjs.map +0 -1
  45. package/dist/matching-Sr0z4HvN.mjs.map +0 -1
  46. package/dist/parts-schema-M1AtjNJt.d.mts.map +0 -1
  47. package/dist/parts-schema-QqLci_Pl.mjs.map +0 -1
  48. package/dist/plugin.d.mts.map +0 -1
  49. package/dist/plugin.mjs.map +0 -1
  50. package/dist/publish-DCXIPwTE.mjs.map +0 -1
  51. package/dist/read-feed-DsLgRDNR.mjs.map +0 -1
  52. package/dist/realtime/index.d.mts.map +0 -1
  53. package/dist/runner-BnFvID1z.mjs.map +0 -1
  54. package/dist/schema-registry-DB5aIZMN.mjs.map +0 -1
  55. package/dist/storage-resolver.d.mts.map +0 -1
  56. package/dist/storage-resolver.mjs.map +0 -1
  57. package/dist/storage.d.mts.map +0 -1
  58. package/dist/storage.mjs.map +0 -1
  59. package/dist/transform-Cu5YEoOl.mjs.map +0 -1
  60. package/dist/transform-WeiiLdYP.d.mts.map +0 -1
  61. package/dist/worker-CRH_PjKO.d.mts.map +0 -1
  62. package/dist/worker.mjs.map +0 -1
  63. package/dist/xlsx-reader-D9Ntxm29.mjs.map +0 -1
@@ -262,4 +262,3 @@ declare function analyzeFeed(options: AnalyzeFeedOptions): Promise<FeedAnalysis>
262
262
  declare function unmappedSources(analysis: FeedAnalysis): string[];
263
263
  //#endregion
264
264
  export { type AnalyzeFeedOptions, CANDIDATE_DELIMITERS, type CandidateDelimiter, DIALECT_SAMPLE_BYTES, DIALECT_SAMPLE_ROWS, type DelimiterInference, FEED_ANALYSIS_VERSION, type FeedAnalysis, FeedAnalysisError, type FeedAnalysisReader, type HeaderInference, type NumberFormatConfidence, type NumberFormatInference, type TrialParse, type TrialParseOptions, type UnmappedColumn, analyzeFeed, delimiterName, inferDelimiter, inferHasHeader, inferNumberFormat, sampleRecords, trialParse, unmappedSources };
265
- //# sourceMappingURL=index.d.mts.map
@@ -1,2 +1 @@
1
1
  import{n as e}from"../read-feed-DsLgRDNR.mjs";import{c as t}from"../parts-schema-QqLci_Pl.mjs";import{n,r}from"../schema-registry-DB5aIZMN.mjs";import{o as i,s as a}from"../matching-Sr0z4HvN.mjs";import{parse as o}from"csv-parse/sync";const s=[` `,`,`,`;`,`|`],c=512*1024,l=200;function u(e){return e===` `?`TAB`:e===` `?`SPACE`:`"${e}"`}function d(e){let t=null;for(let n of s){let r;try{r=o(e,{delimiter:n,columns:!1,bom:!0,skip_empty_lines:!0,relax_column_count:!0,quote:!1,to_line:200})}catch{continue}if(r.length===0)continue;let i=new Map;for(let e of r)i.set(e.length,(i.get(e.length)??0)+1);let a=0,s=0;for(let[e,t]of i)(t>s||t===s&&e>a)&&(a=e,s=t);if(a<2)continue;let c=s/r.length;(t===null||c>t.consistency||c===t.consistency&&a>t.fieldCount)&&(t={delimiter:n,fieldCount:a,consistency:c,evidence:`${u(n)} yields ${a} fields on ${Math.round(c*100)}% of ${r.length} sampled row(s)`})}return t}function f(e,t){let n=(e[0]??[]).map(e=>e.trim());if(e.length===0)return{hasHeader:!1,firstRowCells:n,evidence:`the file has no records`};let r=t(n);if(r>0)return{hasHeader:!0,firstRowCells:n,evidence:`the first row names ${r} recognisable target field(s)`};let i=n.filter(e=>e!==``),a=new Set(i).size===i.length,o=/^-?\d+(?:[., ]\d+)*$/,s=i.length>0&&!n.some(e=>o.test(e)),c=e.slice(1),l=!1;if(c.length>0)for(let e=0;e<n.length;e+=1){let t=0,n=0;for(let r of c){let i=r[e]?.trim();i===void 0||i===``||(n+=1,o.test(i)&&(t+=1))}if(n>=3&&t/n>=.8){l=!0;break}}return s&&a&&l?{hasHeader:!0,firstRowCells:n,evidence:`the first row is entirely non-numeric with distinct values while a column below it is predominantly numeric`}:{hasHeader:!1,firstRowCells:n,evidence:`the first row matches no target field and does not read as a header over the rows below — treating the feed as positional`}}function p(e,t){try{return o(e,{delimiter:t,columns:!1,bom:!0,skip_empty_lines:!0,relax_column_count:!0,quote:!1,to_line:200})}catch{return[]}}async function m(t){let{filePath:n,format:r,delimiter:i,hasHeader:a,quote:o,maxRows:s}=t,c=t.maxSamplesPerColumn??2e3,l=t.maxSamplesTotal??2e4,u=t.maxSampleRows??5,d=new Map,f=[],p=[],m=0,h=0,g=!1;try{for await(let{row:t}of e({filePath:n,format:r,delimiter:i,hasHeader:a,quote:o})){m+=1,p.length===0&&(p=Object.keys(t),!g&&p.some(e=>e.includes(`"`))&&(g=!0)),f.length<u&&f.push({...t});for(let[e,n]of Object.entries(t)){if(n===``||(!g&&n.includes(`"`)&&(g=!0),h>=l))continue;let t=d.get(e);t===void 0?(d.set(e,[n]),h+=1):t.length<c&&(t.push(n),h+=1)}if(s!==void 0&&s>0&&m>=s)break}}catch(e){return{quote:o,ok:!1,error:e instanceof Error?e.message:String(e),rows:m,sawQuoteChar:g,samples:d,sampleRows:f,columns:p}}return{quote:o,ok:!0,error:null,rows:m,sawQuoteChar:g,samples:d,sampleRows:f,columns:p}}const h=[`.`,`,`,` `],g=/^-?\d+(?:[., ]\d+)*$/;function _(){return{decimal:{".":0,",":0," ":0},grouping:{".":0,",":0," ":0},ambiguous:{".":0,",":0," ":0},ambiguousSamples:[]}}function v(e){return e===`.`||e===`,`||e===` `}function y(e){let t=[];for(let n=0;n<e.length;n+=1){let r=e[n];r!==void 0&&v(r)&&t.push({char:r,index:n})}return t}function b(e,t){let n=y(e);if(n.length===0)return;if(new Set(n.map(e=>e.char)).size>=2){let e=n[n.length-1];if(e===void 0)return;t.decimal[e.char]+=1;for(let r of n)r.char!==e.char&&(t.grouping[r.char]+=1);return}let r=n[0]?.char;if(r!==void 0){if(n.length>=2){t.grouping[r]+=1;return}if(e.length-(n[0]?.index??0)-1==3){t.ambiguous[r]+=1,t.ambiguousSamples.length<5&&t.ambiguousSamples.push(e);return}t.decimal[r]+=1}}function x(e){let n=_(),r=0,i=[];for(let t of e){let e=t.trim();e!==``&&g.test(e)&&(r+=1,i.push(e),b(e,n))}n.grouping[` `]+=n.decimal[` `]+n.ambiguous[` `],n.decimal[` `]=0,n.ambiguous[` `]=0;let a=n.decimal[`.`]+n.decimal[`,`],o=n.grouping[`.`]+n.grouping[`,`]+n.grouping[` `],s=n.ambiguous[`.`]+n.ambiguous[`,`],c={considered:e.length,numeric:r,separated:a+o},l=n.decimal[`.`],u=n.decimal[`,`];if(l>0&&u>0)return{format:null,confidence:`conflict`,evidence:`the file argues for BOTH decimal separators — ${l} value(s) put a non-3-digit run after "." and ${u} after ",". One feed is using two conventions, or the delimiter is wrong and two columns have been merged into one.`,unparsed:[],counts:c};let d=null;if(l>0?d=`.`:u>0&&(d=`,`),d===null){if(s>0)return{format:null,confidence:`ambiguous`,evidence:`every separated value has EXACTLY 3 digits after its separator (${h.filter(e=>n.ambiguous[e]>0).map(e=>`"${e}"`).join(`/`)}), e.g. ${n.ambiguousSamples.join(`, `)}. "1,234" is one-thousand-two-hundred-thirty-four or one-point-two-three-four and this file contains nothing that distinguishes them. Guessing here is a silent 1000x error, so state the format explicitly.`,unparsed:[],counts:c};let e=h.find(e=>n.grouping[e]>0);if(e!==void 0){let t=e===`.`?`,`:`.`;return{format:{decimal:t,thousands:e},confidence:`no-evidence`,evidence:`thousands "${e===` `?` `:e}" from ${o} grouped value(s), but NO value shows a decimal separator, so the decimal is a default ("${t}", chosen to differ from the grouping) rather than a reading. Check it against the supplier's own documentation before saving.`,unparsed:[],counts:c}}return{format:{decimal:`.`,thousands:``},confidence:`no-evidence`,evidence:r===0?`no numeric-shaped values were found in the sampled columns.`:`all ${r} numeric sample(s) were bare integers with no separator, so the file says nothing about its decimal separator. Defaulted to "." / none.`,unparsed:[],counts:c}}let f=h.filter(e=>e!==d&&n.grouping[e]>0),p=f[0]??``;if(f.length>1)return{format:null,confidence:`conflict`,evidence:`decimal is "${d}", but ${f.length} different characters (${f.map(e=>`"${e}"`).join(`, `)}) are each used as a thousands separator. A feed uses one.`,unparsed:[],counts:c};let m={decimal:d,thousands:p},v=[];for(let e of i)Number.isNaN(t(e,m))&&v.length<5&&v.push(e);return{format:m,confidence:`high`,evidence:`decimal "${d}" from ${d===`.`?l:u} value(s) with a non-3-digit run after it; thousands ${p===``?`none`:`"${p}"`} from ${o} grouped value(s). ${r} numeric sample(s) considered.`,unparsed:v,counts:c}}const S=1,C=new Set([`supersededno`,`supersededarticle`,`supersedednumber`,`ersatznummer`,`ersatznr`,`replacementcode`,`replacementno`]),w=[`superseding_code`,`replaces_code`];var T=class extends Error{hint;constructor(e,t){super(e),this.name=`FeedAnalysisError`,this.hint=t}};function E(e){return e===`number`?[{op:`trim`},{op:`number`}]:[{op:`trim`}]}function D(e){return e.toLowerCase().replace(/[^a-z0-9]/g,``)}async function O(e,t){let{open:n}=await import(`node:fs/promises`),r=await n(e,`r`);try{let e=Buffer.alloc(t),{bytesRead:n}=await r.read(e,0,t,0);return e.subarray(0,n).toString(`utf8`)}finally{await r.close()}}async function k(e){let{stat:t}=await import(`node:fs/promises`);return(await t(e)).size}function A(e,t){if(t===null)return{chosen:e,evidence:`no quote character appears anywhere in ${e.rows} scanned row(s), so quoting is immaterial; parsed ${e.rows} row(s) cleanly with quotes as data`,warning:null};if(e.ok&&!t.ok)return{chosen:e,evidence:`quotes are ordinary data: parsed ${e.rows} row(s) cleanly, while RFC 4180 failed after ${t.rows} row(s) — ${t.error}`,warning:null};if(!e.ok&&t.ok)return{chosen:t,evidence:`RFC 4180 quoting: parsed ${t.rows} row(s) cleanly, while treating quotes as data failed after ${e.rows} row(s) — ${e.error}`,warning:null};if(e.ok&&t.ok)return{chosen:t,evidence:`BOTH policies parse this file cleanly (${e.rows} row(s) each); chose RFC 4180 because every quote character sits at a field boundary`,warning:`Quoting is ambiguous — both policies parse this file. RFC 4180 was chosen, which STRIPS the enclosing quotes from field values; treating them as data would keep them. Check a sample row before saving, and set the policy explicitly if the other reading is right.`};throw new T(`neither quoting policy can parse this file.\n quotes-as-data: ${e.error} (after ${e.rows} row(s))\n RFC 4180: ${t.error} (after ${t.rows} row(s))`,`Both failures usually mean the DELIMITER or the header verdict is wrong, not the quoting. Check the reported delimiter against the file, and re-run with an explicit --delimiter.`)}async function j(e){let{filePath:t}=e,o=e.targetSchema??`parts`,s=n(o);if(s===void 0)throw new T(`Unknown target schema "${o}".`,`Registered schemas: ${r().join(`, `)}`);let l=e.readSample??O,u=await(e.fileSize??k)(t),h=await l(t,c);if(h.trim()===``)throw new T(`${t} is empty.`);let g=d(h);if(g===null)throw new T(`Could not determine a field delimiter for ${t}.`,`No candidate (TAB, comma, semicolon, pipe) split the sample into more than one column consistently. If the feed uses another delimiter, or is a spreadsheet, map it in the admin wizard instead.`);let{delimiter:_}=g,v=_===`,`?`csv`:`tsv`,y=f(p(h,_),e=>a(e.filter(e=>e!==``),s.fields).size),{hasHeader:b}=y,S={filePath:t,format:v,delimiter:_,hasHeader:b,...e.maxRows!==void 0&&{maxRows:e.maxRows}},C=await m({...S,quote:!1}),{chosen:w,evidence:D,warning:j}=A(C,C.sawQuoteChar||!C.ok?await m({...S,quote:`"`}):null),N=[];j!==null&&N.push(j);let P=w.columns;if(P.length===0)throw new T(`${t} parsed to zero columns.`,`The file may be empty below its header.`);let F=a(P,s.fields),I=new Map(s.fields.map(e=>[e.key,e])),L=[],R=[];for(let e of P){let t=F.get(e);if(t!==void 0){let n=I.get(t);L.push({source:e,target:t,transforms:E(n?.type??`string`)});continue}R.push(M(e))}let z=L.filter(e=>I.get(e.target)?.type===`number`).map(e=>e.source),B=[];for(let e of z)for(let t of w.samples.get(e)??[])B.push(t);let V=x(B);V.format===null&&N.push(`The number format could NOT be decided (${V.confidence}): ${V.evidence} Supply { decimal, thousands } explicitly before saving.`),V.unparsed.length>0&&N.push(`${V.unparsed.length}+ sampled value(s) in the number columns do not parse under the inferred format: ${V.unparsed.join(`, `)}. Those rows will be row errors at import.`),z.length===0&&N.push(`No column mapped to a numeric target, so the number format was inferred from nothing. Check it before saving.`);let H=b?i({columns:[...P],rows:w.sampleRows}):null;return{version:1,file:{path:t,bytes:u},targetSchema:o,reader:{format:v,delimiter:_,hasHeader:b,quote:w.quote},numberFormat:V.format,numberFormatConfidence:V.confidence,columnMap:L,unmapped:R,headerSnapshot:H,dataRows:w.rows,truncated:e.maxRows!==void 0&&e.maxRows>0&&w.rows>=e.maxRows,evidence:{delimiter:g.evidence,hasHeader:y.evidence,quote:D,numberFormat:V.evidence},warnings:N}}function M(e){return C.has(D(e))?{source:e,reason:`"${e}" names a replacement code but not its DIRECTION, and the two readings are opposites: the column may hold the NEWER code that supersedes this row (superseding_code), or the OLDER code this row supersedes (replaces_code). This is deliberately not guessed — an inverted feed builds well-formed chains with valid heads and no warnings, and the merge is append-only, so re-importing the right way round does NOT repair it. Check a superseded code against the supplier's current catalogue and state the direction.`,candidates:[...w]}:{source:e,reason:`no target field matches "${e}".`,candidates:[]}}function N(e){return e.unmapped.map(e=>e.source)}export{s as CANDIDATE_DELIMITERS,c as DIALECT_SAMPLE_BYTES,l as DIALECT_SAMPLE_ROWS,S as FEED_ANALYSIS_VERSION,T as FeedAnalysisError,j as analyzeFeed,u as delimiterName,d as inferDelimiter,f as inferHasHeader,x as inferNumberFormat,p as sampleRecords,m as trialParse,N as unmappedSources};
2
- //# sourceMappingURL=index.mjs.map
@@ -1,2 +1 @@
1
1
  import{n as e,r as t}from"./parts-schema-QqLci_Pl.mjs";import{t as n}from"./engine-B0ExE4jL.mjs";const r=`commerce:carmaker-feed`,i=e,a={targetSchema:`parts`,numberFormat:{decimal:`,`,thousands:`.`},columnMap:[{source:`ArticleName`,target:`code`,transforms:[{op:`trim`}]},{source:`NetPrice/Discount`,target:`net_price`,transforms:[{op:`europeanNumber`}]},{source:`GrossPrice`,target:`gross_price`,transforms:[{op:`europeanNumber`}]},{source:`CurrencyCd`,target:`currency`,transforms:[{op:`trim`}]},{source:`Barcode`,target:`barcode`,transforms:[{op:`trim`}]},{source:`Description1`,target:`description1`,transforms:[{op:`trim`}]},{source:`Description2`,target:`description2`,transforms:[{op:`trim`}]},{source:`Description_DE`,target:`name_de`,transforms:[{op:`trim`}]},{source:`Description_EN`,target:`name_en`,transforms:[{op:`trim`}]},{source:`Description_ES`,target:`name_es`,transforms:[{op:`trim`}]},{source:`Description_FR`,target:`name_fr`,transforms:[{op:`trim`}]},{source:`Description_IT`,target:`name_it`,transforms:[{op:`trim`}]},{source:`Description_NL`,target:`name_nl`,transforms:[{op:`trim`}]},{source:`Description_PT`,target:`name_pt`,transforms:[{op:`trim`}]},{source:`Description_RO`,target:`name_ro`,transforms:[{op:`trim`}]}]},o=n(a,t);export{o as i,a as n,i as r,r as t};
2
- //# sourceMappingURL=carmaker-feed-B-QYr3PI.mjs.map
@@ -56,4 +56,3 @@ declare const CARMAKER_MAPPING: MappingConfig;
56
56
  declare const carmakerFeedTransform: RowTransform<PartsDocument>;
57
57
  //#endregion
58
58
  export { carmakerFeedTransform as a, carmakerFeedParamsSchema as i, CARMAKER_MAPPING as n, CarmakerFeedParams as r, CARMAKER_FEED_TRANSFORM_NAME as t };
59
- //# sourceMappingURL=carmaker-feed-CWrZLYUt.d.mts.map
@@ -1,2 +1 @@
1
1
  import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{getImportsStorage as t}from"./storage.mjs";var n=e({cleanupOrphanedImports:()=>a,cleanupProcessedImports:()=>s});const r=1e3;function i(e){let{ttlOrRetention:t,ttlLabel:n,limit:i=100}=e;if(!Number.isFinite(t)||t<0)throw Error(`imports cleanup: ${n} must be a finite, non-negative number (got ${t})`);if(!Number.isFinite(i)||!Number.isInteger(i)||i<1||i>r)throw Error(`imports cleanup: limit must be an integer in [1, ${r}] (got ${i})`);return{ttlOrRetention:t,limit:i}}async function a(e){let{app:n,bucket:r,logger:a}=e,o=n.db.readWrite,{ttlOrRetention:s,limit:c}=i({ttlOrRetention:e.ttlHours,ttlLabel:`ttlHours`,...e.limit!==void 0&&{limit:e.limit}}),{and:l,asc:u,eq:d,isNull:f,lt:p,getTableColumns:m}=await import(`drizzle-orm`),{toolkitFiles:h}=await import(`@murumets-ee/storage`),{createAdminClient:g}=await import(`@murumets-ee/core/clients`),{ImportRun:_}=await import(`./transform-Cu5YEoOl.mjs`).then(e=>e.d),v=g(_).getTable(),y=m(h),b=y.key,x=y.bucket,S=y.createdAt;if(!b||!x||!S)throw Error(`cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)`);let C=m(v),w=C.filePath,T=C.id;if(!w||!T)throw Error(`cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)`);let E=new Date(Date.now()-s*60*60*1e3),D=await o.select({key:b}).from(h).leftJoin(v,d(w,b)).where(l(d(x,r),p(S,E),f(T))).orderBy(u(S)).limit(c);if(D.length===0)return a.debug({bucket:r,ttlHours:s},`No orphaned imports uploads found`),0;let O=await t(),k=0;for(let e of D){let t=e.key;try{await O.delete(t),k++}catch(e){a.warn({key:t,err:e},`Failed to delete orphaned imports upload`)}}return a.info({cleaned:k,total:D.length,bucket:r,ttlHours:s},`Cleaned up orphaned imports uploads`),k}const o=[`succeeded`,`failed`,`cancelled`];async function s(e){let{app:n,bucket:r,logger:a}=e,s=n.db.readWrite,{ttlOrRetention:c,limit:l}=i({ttlOrRetention:e.retentionDays,ttlLabel:`retentionDays`,...e.limit!==void 0&&{limit:e.limit}}),{and:u,asc:d,eq:f,getTableColumns:p,inArray:m,lt:h}=await import(`drizzle-orm`),{toolkitFiles:g}=await import(`@murumets-ee/storage`),{createAdminClient:_}=await import(`@murumets-ee/core/clients`),{ImportRun:v}=await import(`./transform-Cu5YEoOl.mjs`).then(e=>e.d),y=_(v).getTable(),b=p(g),x=b.key,S=b.bucket;if(!x||!S)throw Error(`cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)`);let C=p(y),w=C.filePath,T=C.status,E=C.finishedAt;if(!w||!T||!E)throw Error(`cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)`);let D=new Date(Date.now()-c*24*60*60*1e3),O=await s.selectDistinctOn([x],{key:x}).from(g).innerJoin(y,f(w,x)).where(u(f(S,r),m(T,[...o]),h(E,D))).orderBy(d(x),d(E)).limit(l);if(O.length===0)return a.debug({bucket:r,retentionDays:c},`No processed-imports uploads ready for cleanup`),0;let k=await t(),A=0;for(let e of O){let t=e.key;try{await k.delete(t),A++}catch(e){a.warn({key:t,err:e},`Failed to delete processed-imports upload`)}}return a.info({cleaned:A,total:O.length,bucket:r,retentionDays:c},`Cleaned up processed imports uploads`),A}export{s as n,n as r,a as t};
2
- //# sourceMappingURL=cleanup-DDljBe0Z.mjs.map
@@ -1,2 +1 @@
1
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
@@ -606,4 +606,3 @@ interface ResolvableMapping {
606
606
  declare function resolveMapping<M extends ResolvableMapping>(mappings: readonly M[], brandId: string | null): M | undefined;
607
607
  //#endregion
608
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-tkbUXKxB.d.mts.map
@@ -94,4 +94,3 @@ declare const SNAPSHOT_MAX_SERIALIZED_CHARS = 100000;
94
94
  declare function toHeaderSnapshot(preview: HeaderSample): HeaderSample | null;
95
95
  //#endregion
96
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
package/dist/index.d.mts CHANGED
@@ -387,4 +387,3 @@ interface FeedPreview {
387
387
  declare function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview>;
388
388
  //#endregion
389
389
  export { type AuditEmptyHeaderRunsArgs, type AuditedImportRun, CARMAKER_MAPPING, type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, DEFAULT_PREVIEW_ROWS, type DirectionalPair, EMPTY_HEADER_VERDICTS, EMPTY_PRICE_STATS, type EmptyHeaderAuditReport, type EmptyHeaderVerdict, type EnsureIndexResolver, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type ErrorTrackerSnapshot, type EsClientResolver, type FeedPreview, type FeedReaderConfig, type FilePathResolver, IMPORT_MAPPING_FORMATS, IMPORT_MAPPING_TARGET_SCHEMAS, IMPORT_RUN_STATUSES, ImportMapping, type ImportMappingClient, type ImportMappingFormat, type ImportMappingTargetSchema, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, type InspectImportRunSourceArgs, type InspectImportRunSourceResult, MAX_PREVIEW_ROWS, type MappingColumn, type MappingConfig, PRICE_ANOMALY_THRESHOLD_MULTIPLIER, PRICE_SAMPLE_CAP, type PairSink, type PairSinkContext, type PreviewFeedOptions, type PriceAnomaly, type PriceStats, PriceStatsCollector, type ReadFeedOptions, type RecordedTotals, type RowError, type RowResult, type RowTransform, type RowWarning, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, assertValidMapping, auditEmptyHeaderRuns, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, detectPriceAnomaly, feedReaderConfigSchema, getTransformRegistry, importMappingValidation, inspectImportRunSource, mappingColumnSchema, mappingConfigSchema, mergeErrorTrackerSnapshots, parsePriceStats, previewFeed, readFeed, readPersistedFeedConfig, readRecordedTotals, registerImportTransform, runImport, streamFeed };
390
- //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,2 +1 @@
1
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-DsLgRDNR.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-BnFvID1z.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-D9Ntxm29.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
- //# sourceMappingURL=index.mjs.map
@@ -39,4 +39,3 @@ interface TargetSchemaSummary {
39
39
  declare function describeTargetSchemas(): TargetSchemaSummary[];
40
40
  //#endregion
41
41
  export { type BuildRowContext, COLUMN_TRANSFORM_OPS, type CellValue, type ColumnTransform, type ColumnTransformOp, type ColumnTransformOpDescriptor, type DiscountTableRow, MAPPING_TRANSFORM_NAME, type MappedRow, type MappingColumn, type MappingConfig, type NumberFormat, type NumberOutcome, type PartsParams, type ResolvableMapping, type TargetField, type TargetSchema, type TargetSchemaSummary, applyColumnTransforms, buildMappingTransform, columnTransformSchema, describeTargetSchemas, discountTableSchema, getTargetSchema, listTargetSchemas, mappingColumnSchema, mappingConfigSchema, mappingTransform, normalizeCode, numberCell, numberFormatSchema, parseEuropeanNumber, parseNumber, partsParamsSchema, partsSchema, resolveMapping, rowError, textOrNull, toMinorUnits };
42
- //# sourceMappingURL=index.d.mts.map
@@ -1,2 +1 @@
1
1
  import{r as e,t}from"./engine-B0ExE4jL.mjs";import{n,r}from"./schema-registry-DB5aIZMN.mjs";import{z as i}from"zod";const a=`commerce:mapping`,o=i.object({mapping:e}),s=new WeakMap;function c(e){let i=s.get(e);if(i)return i;let{mapping:a}=o.parse(e),c=n(a.targetSchema);if(!c)throw Error(`Unknown target schema "${a.targetSchema}" (registered: ${r().join(`, `)})`);let l=t(a,c);return s.set(e,l),l}const l=async(e,t)=>c(t.params)(e,t);export{l as n,a as t};
2
- //# sourceMappingURL=mapping-transform-CO4MNhMe.mjs.map
@@ -1,2 +1 @@
1
1
  function e(e){return e.toLowerCase().replace(/[^a-z0-9]/g,``)}function t(e){return e.replace(/([a-z0-9])([A-Z])/g,`$1 $2`).toLowerCase().split(/[^a-z0-9]+/).filter(e=>e!==``).slice(0,12)}function n(e){let t=[];for(let n=0;n<e.length;n++){let r=``;for(let i=n;i<e.length;i++)r+=e[i]??``,t.push(r)}return t}function r(r,i){let a=i.map(t=>{let n=new Set([e(t.key),e(t.label)]);for(let r of t.aliases??[])n.add(e(r));return n.delete(``),n}),o=[];r.forEach((r,i)=>{let s=e(r);if(s===``)return;let c=new Set(n(t(r)));a.forEach((e,t)=>{let n=0;for(let t of e)s===t?n=Math.max(n,1e3+t.length):c.has(t)&&(n=Math.max(n,t.length));n>0&&o.push({columnIndex:i,fieldIndex:t,score:n})})}),o.sort((e,t)=>t.score-e.score||e.fieldIndex-t.fieldIndex||e.columnIndex-t.columnIndex);let s=new Map,c=new Set,l=new Set;for(let e of o){if(c.has(e.columnIndex)||l.has(e.fieldIndex))continue;let t=r[e.columnIndex],n=i[e.fieldIndex];t===void 0||n===void 0||(c.add(e.columnIndex),l.add(e.fieldIndex),s.set(t,n.key))}return s}const i=256,a=256,o=5,s=500,c=1e5;function l(e){let t=e.columns.filter(e=>e.length>0);if(t.length===0||t.length>256||t.some(e=>e.length>256))return null;let n={columns:t,rows:e.rows.slice(0,5).map(e=>{let t={};for(let[n,r]of Object.entries(e))n.length!==0&&(t[n]=u(r));return t})};return JSON.stringify(n).length>1e5?null:n}function u(e){if(e.length<=500)return e;let t=e.slice(0,500),n=t.charCodeAt(t.length-1);return n>=55296&&n<=56319?t.slice(0,-1):t}export{c as a,t as c,o as i,i as n,l as o,a as r,r as s,s as t};
2
- //# sourceMappingURL=matching-Sr0z4HvN.mjs.map
@@ -58,4 +58,3 @@ declare function normalizeCode(s: string): string;
58
58
  declare const partsSchema: TargetSchema<PartsDocument>;
59
59
  //#endregion
60
60
  export { partsSchema as i, normalizeCode as n, partsParamsSchema as r, PartsParams as t };
61
- //# sourceMappingURL=parts-schema-M1AtjNJt.d.mts.map
@@ -1,2 +1 @@
1
1
  import{z as e}from"zod";const t=/^-?\d+(\.\d+)?$/;function n(e,n){let r=e.trim();if(r===``||r.toUpperCase()===`NA`)return null;let{decimal:i,thousands:a}=n,o=r;if(a!==``&&(o=o.split(a).join(``)),i===`,`){if(o.includes(`.`))return NaN;o=o.split(`,`).join(`.`)}else if(o.includes(`,`))return NaN;return t.test(o)?Number.parseFloat(o):NaN}function r(e){return Math.round(e*100)}function i(e){let n=e.trim();if(n===``||n.toUpperCase()===`NA`)return null;let r=n.includes(`,`),i=n.includes(`.`),a;if(r&&i)a=n.replace(/\./g,``).replace(`,`,`.`);else if(r)a=n.replace(`,`,`.`);else if(i)return NaN;else a=n;return t.test(a)?Number.parseFloat(a):NaN}function a(e){if(typeof e!=`string`)return null;let t=e.trim();return t===``?null:t}function o(e){return e==null?{kind:`absent`}:typeof e==`number`?Number.isNaN(e)?{kind:`invalid`}:{kind:`value`,value:e}:e.trim()===``?{kind:`absent`}:{kind:`invalid`}}function s(e,t,n){return{kind:`error`,error:{errorType:e,message:t,field:n}}}const c=e.object({brandId:e.string().uuid(),brandSlug:e.string().min(1).max(64),supplierId:e.string().uuid(),supplierDisplayName:e.string().min(1).max(255),importBatchId:e.string().uuid(),supplierCodePrefix:e.string().max(8).regex(/^[A-Z0-9-]+$/,`must be uppercase alphanumeric or "-"`).optional(),discountGroups:e.record(e.string(),e.number().min(0).max(100)).optional()}),l=new WeakMap;function u(e){let t=l.get(e);if(t)return t;let n=c.parse(e);return l.set(e,n),n}const d=[`de`,`en`,`es`,`fr`,`it`,`nl`,`pt`,`ro`],f=e=>`name_${e}`,p=[{key:`code`,label:`Code`,required:!0,type:`string`,aliases:[`part no`,`part number`,`article name`,`article no`,`article number`,`sku`,`varuosa kood`,`kood`,`artikelnummer`,`artikel nr`]},{key:`net_price`,label:`Net price`,required:!1,type:`number`,aliases:[`price`,`net price discount`,`nettopreis`,`netto`]},{key:`gross_price`,label:`Gross price`,required:!1,type:`number`,aliases:[`bruttopreis`,`brutto`]},{key:`list_price`,label:`List price`,required:!1,type:`number`,aliases:[`retail price`,`rrp`,`jaehind`,`jaehind km ta`,`listenpreis`]},{key:`currency`,label:`Currency`,required:!1,type:`string`,aliases:[`currency cd`,`currency code`,`valuuta`]},{key:`barcode`,label:`Barcode`,required:!1,type:`string`,aliases:[`ean`,`ean code`,`gtin`,`gtin code`,`triipkood`]},{key:`description1`,label:`Description 1`,required:!1,type:`string`,aliases:[`description`,`kirjeldus`,`beschreibung`]},{key:`description2`,label:`Description 2`,required:!1,type:`string`},{key:`discount_group`,label:`Discount group`,required:!1,type:`string`,aliases:[`product group`,`tootegrupp`,`rabattgruppe`]},{key:`superseding_code`,label:`Replaced by (newer code)`,required:!1,type:`string`,aliases:[`replaced by`,`successor`,`successor code`,`new code`,`ersetzt durch`,`wird ersetzt durch`,`nachfolgenummer`,`vervangen door`]},{key:`replaces_code`,label:`Replaces (older code)`,required:!1,type:`string`,aliases:[`replaces`,`supersedes`,`old code`,`previous code`,`predecessor`,`vorgaengernummer`]},{key:`core_charge`,label:`Core charge`,required:!1,type:`number`,aliases:[`surcharge`,`deposit`,`core deposit`,`korpusetasu`,`pfand`]},...d.map(e=>({key:f(e),label:`Name (${e})`,required:!1,type:`string`,aliases:[`description ${e}`,`name ${e}`]}))],m=/[\s-]/g;function h(e){return e.toUpperCase().replace(m,``)}function g(e,t){let n=a(e);if(n===null)return null;let r=h(t&&n.startsWith(t)?n.slice(t.length):n);return r===``?null:r}function _(e,t){return Math.round(e*(1-t/100))}function v(e){return e===null?{core_charge_minor:null,core_charge_currency:null,core_charge_refundable:null,core_charge_tax_class:null,core_charge_tax_inclusive:null}:{core_charge_minor:e,core_charge_currency:`EUR`,core_charge_refundable:!0,core_charge_tax_class:null,core_charge_tax_inclusive:!1}}const y={name:`parts`,fields:p,buildRow(e,t){let n=u(t.params),i=typeof e.code==`string`?e.code.trim():``;if(!i)return s(`missing_code`,`code is empty`,`code`);let c=h(n.supplierCodePrefix&&i.startsWith(n.supplierCodePrefix)?i.slice(n.supplierCodePrefix.length):i);if(!c)return s(`empty_normalized_code`,`Code "${i}" is empty after normalization`,`code`);let l=a(e.currency)??`EUR`;if(l!==`EUR`)return s(`unsupported_currency`,`Currency "${l}" not supported (EUR-only)`,`currency`);if(c.endsWith(`*S`)){let t=c.slice(0,-2);if(!t)return s(`core_charge_no_parent`,`Core-fee row "${i}" has no part code before the "*S" marker`,`code`);let a=o(e.net_price);if(a.kind===`invalid`)return s(`invalid_core_charge`,`core-fee (*S) row net_price is not a number`,`net_price`);if(a.kind===`absent`)return s(`missing_core_charge`,`core-fee (*S) row has no net_price to use as the deposit`,`net_price`);let l=r(a.value);return l<=0?{kind:`skip`,reason:`core_fee_zero`}:{kind:`skip`,reason:`core_charge_marker`,patches:[{id:`${t}__${n.supplierId}`,doc:v(l),orphanWarning:{warningType:`orphan_core_charge`,message:`core-fee (*S) row references a part not present in this import`,field:`code`}}]}}let d=o(e.net_price);if(d.kind===`invalid`)return s(`invalid_net_price`,`net_price is not a number`,`net_price`);let f=o(e.gross_price);if(f.kind===`invalid`)return s(`invalid_gross_price`,`gross_price is not a number`,`gross_price`);let p=o(e.list_price);if(p.kind===`invalid`)return s(`invalid_list_price`,`list_price is not a number`,`list_price`);let m=d.kind===`value`?d.value:null,y=p.kind===`value`?p.value:null,b=m??y;if(b===null)return s(`missing_price`,`no net_price or list_price provided`,`price`);let x=a(e.discount_group),S=y===null?null:r(y),C=null,w=[];if(m!==null)C=r(m);else if(S!==null){let e=x===null?void 0:n.discountGroups?.[x];e===void 0?w.push({warningType:`no_purchase_price`,message:x===null?`list price present but no discount group on the row — cost pending`:`no discount group "${x}" configured for this supplier — cost pending`,field:`discount_group`}):C=_(S,e)}let T=[],E=g(e.superseding_code,n.supplierCodePrefix);E!==null&&E!==c&&T.push({from:c,to:E});let D=g(e.replaces_code,n.supplierCodePrefix);D!==null&&D!==c&&T.push({from:D,to:c});let O=o(e.core_charge);if(O.kind===`invalid`)return s(`invalid_core_charge`,`core_charge is not a number`,`core_charge`);let k=O.kind===`value`?r(O.value):null,A=k!==null&&k>0?k:null,j=`${c}__${n.supplierId}`;return{kind:`success`,id:j,doc:{doc_id:j,code:i,code_normalized:c,brand_id:n.brandId,brand_slug:n.brandSlug,supplier_id:n.supplierId,supplier_display_name:n.supplierDisplayName,net_price_eur:b,gross_price_eur:f.kind===`value`?f.value:null,currency:`EUR`,barcode:a(e.barcode),name_de:a(e.name_de),name_en:a(e.name_en),name_es:a(e.name_es),name_fr:a(e.name_fr),name_it:a(e.name_it),name_nl:a(e.name_nl),name_pt:a(e.name_pt),name_ro:a(e.name_ro),description1:a(e.description1),description2:a(e.description2),list_price_minor:S,discount_group:x,superseding_code:a(e.superseding_code),purchase_price_minor:C,...v(A),import_batch_id:n.importBatchId,imported_at:new Date().toISOString()},...w.length>0&&{warnings:w},...T.length>0&&{pairs:T},...C!==null&&{priceSample:C}}}};export{s as a,n as c,o as i,r as l,c as n,a as o,y as r,i as s,h as t};
2
- //# sourceMappingURL=parts-schema-QqLci_Pl.mjs.map
package/dist/plugin.d.mts CHANGED
@@ -145,4 +145,3 @@ declare function imports(options: ImportsPluginOptions): Pick<import("@murumets-
145
145
  };
146
146
  //#endregion
147
147
  export { ImportsPluginOptions, imports };
148
- //# sourceMappingURL=plugin.d.mts.map
package/dist/plugin.mjs CHANGED
@@ -1,2 +1 @@
1
1
  import{n as e,o as t,u as n}from"./transform-Cu5YEoOl.mjs";import{registerImportsStorageFactory as r}from"./storage.mjs";import{i,t as a}from"./carmaker-feed-B-QYr3PI.mjs";import{n as o,t as s}from"./mapping-transform-CO4MNhMe.mjs";import{createImportsRunDeadListener as c,createRunImportHandler as l,importsRunJob as u}from"./worker.mjs";import{definePlugin as d}from"@murumets-ee/core";import{z as f}from"zod";import{inArray as p}from"drizzle-orm";import{createIndex as m,dropIndex as h,readAliasTargets as g,reindex as _}from"@murumets-ee/search-elasticsearch";import{defineJob as v}from"@murumets-ee/queue/client";const y=f.object({toIndex:f.string().min(1).optional(),allowExisting:f.boolean().optional(),dropOld:f.boolean().optional(),batchSize:f.number().int().min(1).max(1e4).optional()}),b=v({name:`imports:reindex`,description:`Copy the aliased parts index into a new physical index built with the current mapping, then atomically swap the alias onto it.`,schema:y,defaultRetries:0}),x=[`pending`,`running`];async function S(e){let t=e.getTable();return e.count({where:p(t.status,[...x])})}function C(e){let t=/^(.*)_v(\d+)$/.exec(e);if(!t)return null;let[,n,r]=t;if(n===void 0||r===void 0)return null;let i=Number.parseInt(r,10)+1;return Number.isSafeInteger(i)?`${n}_v${i}`:null}function w(e){let{esClient:t,esIndex:n,indexConfig:r,importRuns:i,logger:a}=e;return async e=>{let o=a?.child({jobId:e.id,type:`imports:reindex`});if(r===void 0)throw Error(`imports:reindex requires imports({ indexConfig }) — the destination index must be created with the settings + mappings this app intends, otherwise Elasticsearch auto-creates it with inferred text mappings and the reindex silently discards the mapping change it exists to ship. Pass your app's index config (for the parts catalog: partsIndexConfig from @murumets-ee/search-elasticsearch).`);let s=y.parse(e.payload);if(i!==void 0){let e=await S(i);if(e>0)throw Error(`imports:reindex: ${e} feed import(s) are pending or running. An import writes through the alias "${n}", which points at the SOURCE index for the whole copy, so anything it writes now would be lost at the swap. Wait for them to finish (or cancel them) and re-run.`)}let c=await t(),l=Object.keys(await g(c,n)).sort();if(l.length>1)throw Error(`imports:reindex: alias "${n}" points at ${l.length} indices (${l.join(`, `)}). Reindexing would copy only one of them and then swap, leaving the alias on a mix of stale and fresh data. Resolve the alias to a single index first, then re-run. An interrupted reindex cannot cause this — swapAlias sends its remove with must_exist, so a stale swap fails instead of adding a second target. Look for a manual alias edit, another tool writing to this cluster, or a swap issued before that guard existed.`);let u=l[0];if(u===void 0)throw Error(`imports:reindex: alias "${n}" does not exist, so there is nothing to reindex. Run an import first — imports({ ensureIndex }) bootstraps the alias on the import path.`);let d=s.toIndex??C(u);if(d===null)throw Error(`imports:reindex: alias "${n}" points at "${u}", which does not follow the "<name>_v<N>" convention, so the next version cannot be derived. Pass an explicit destination (\`lumi imports:reindex --to <index>\`).`);let f=d;if(f===u)throw Error(`imports:reindex: destination "${f}" is the index the alias already points at. Reindexing an index onto itself would copy every document over itself and swap the alias onto where it already is. Pass a different --to.`);let p=await c.indices.exists({index:f});if(p&&s.allowExisting!==!0)throw Error(`imports:reindex: destination "${f}" already exists. If it is debris from a failed run, re-run with --resume to continue into it (bulkUpsert is idempotent by _id, so already-copied documents are simply overwritten); if it was created by hand with a different mapping, delete it or pass --to <another-index> instead. Refusing to reuse it silently, because doing so would apply ITS mapping rather than the one you changed.`);e.updateProgress({phase:p?`resuming`:`creating`,fromIndex:u,toIndex:f,processed:0,total:0}),p?o?.warn({fromIndex:u,toIndex:f},`imports:reindex: resuming into an existing destination index (--resume); its EXISTING mapping applies, not indexConfig`):(o?.info({fromIndex:u,toIndex:f},`imports:reindex: creating destination index`),await m(c,{name:f,config:r})),o?.info({fromIndex:u,toIndex:f},`imports:reindex: copying documents`);let v=await _({client:c,alias:n,fromIndex:u,toIndex:f,refreshOnCompletion:!0,...s.batchSize!==void 0&&{batchSize:s.batchSize},...i!==void 0&&{beforeSwap:async()=>{let e=await S(i);if(e>0)throw Error(`imports:reindex: ${e} feed import(s) started while the copy was running. Their documents went to the source index through the alias and are NOT in "${f}", so swapping now would drop them. The alias is untouched and "${f}" is left in place — wait for the import(s), then re-run with --resume to finish.`)}},reportProgress:async({processed:t,total:n})=>{e.updateProgress({phase:`copying`,fromIndex:u,toIndex:f,processed:t,total:n})}});e.updateProgress({phase:`swapped`,fromIndex:u,toIndex:f,processed:v.processed,total:v.processed+v.skipped,skipped:v.skipped}),o?.info({fromIndex:u,toIndex:f,processed:v.processed,skipped:v.skipped},`imports:reindex: alias swapped onto destination`),s.dropOld===!0&&(await h(c,u),o?.info({fromIndex:u},`imports:reindex: dropped source index (--drop-old)`))}}const T=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function E(f){let p=f.esIndex,m=f.storageBucket===null?null:{bucket:f.storageBucket??`lumi-imports`,orphanTtlHours:f.orphanUploadTtlHours??2,processedRetentionDays:f.processedRetentionDays??7};return f.storage&&r(f.storage),d({name:`@murumets-ee/imports`,server:{entities:[n,t],init:async t=>{let r=e();if(r.has(`commerce:mapping`)||r.register(s,o),r.has(`commerce:carmaker-feed`)||r.register(a,i),!t.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){t.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let d=await import(`@murumets-ee/queue/client`),h=t.getClient(n);d.registerJob(u,l({importRuns:h,transforms:r,esClient:f.esClient,esIndex:p,...f.ensureIndex!==void 0&&{ensureIndex:f.ensureIndex},...f.resolveFilePath!==void 0&&{resolveFilePath:f.resolveFilePath},...f.pairSink!==void 0&&{pairSink:f.pairSink},logger:t.logger.child({pkg:`imports`})})),d.registerJob(b,w({esClient:f.esClient,esIndex:p,importRuns:h,...f.indexConfig!==void 0&&{indexConfig:f.indexConfig},logger:t.logger.child({pkg:`imports`})}));let g=globalThis[T];g&&g();let _=d.addQueueTerminalListener(c({importRuns:h,logger:t.logger.child({pkg:`imports`})}));if(globalThis[T]=_,m!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:n}=await import(`./cleanup-DDljBe0Z.mjs`).then(e=>e.r),{runAsCli:r}=await import(`@murumets-ee/core`),i=t.logger.child({pkg:`imports`,job:`cleanup`});d.registerJob(d.defineJob({name:`imports:cleanup-orphaned-uploads`,description:`Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.`,schedule:`@every 1h`}),async()=>{await r(()=>e({app:t,bucket:m.bucket,ttlHours:m.orphanTtlHours,logger:i}))}),d.registerJob(d.defineJob({name:`imports:cleanup-processed-uploads`,description:`Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.`,schedule:`@every 1h`}),async()=>{await r(()=>n({app:t,bucket:m.bucket,retentionDays:m.processedRetentionDays,logger:i}))})}t.logger.info({esIndex:p,cleanup:m===null?{enabled:!1}:{enabled:!0,...m}},`Imports plugin initialized`)}}})}export{E as imports};
2
- //# sourceMappingURL=plugin.mjs.map
@@ -1,2 +1 @@
1
1
  import{createLogger as e}from"@murumets-ee/logging";const t={runCreated:`imports.run.created`,runStarted:`imports.run.started`,runSucceeded:`imports.run.succeeded`,runFailed:`imports.run.failed`},n=e({name:`imports:realtime`}),r={admin:!0};let i=null;function a(e,t){e instanceof Error&&e.name===`RealtimeOversizeError`?n.error({err:e,topic:t},`realtime publish failed — payload oversize`):n.warn({err:e,topic:t},`realtime publish failed — event dropped`)}function o(e,t){if(i){try{i({topic:e,payload:t,scope:r})}catch(t){a(t,e)}return}import(`@murumets-ee/core/realtime`).then(n=>{i=n.publishEvent,i({topic:e,payload:t,scope:r})}).catch(t=>{a(t,e)})}async function s(){i||=(await import(`@murumets-ee/core/realtime`)).publishEvent}function c(e,n){o(t.runCreated,{importRunId:e,...n.brandSlug!==void 0&&{brandSlug:n.brandSlug},...n.supplierDisplayName!==void 0&&{supplierDisplayName:n.supplierDisplayName},filename:n.filename})}function l(e){o(t.runStarted,{importRunId:e})}function u(e,n){o(t.runSucceeded,{importRunId:e,rowsRead:n.rowsRead,rowsSucceeded:n.rowsSucceeded,rowsFailed:n.rowsFailed})}function d(e,n){o(t.runFailed,{importRunId:e,reason:n})}export{u as a,l as i,c as n,t as o,d as r,s as t};
2
- //# sourceMappingURL=publish-DCXIPwTE.mjs.map
@@ -1,2 +1 @@
1
1
  import{z as e}from"zod";import{createReadStream as t}from"node:fs";import{parse as n}from"csv-parse";function r(e){let t=new Set;for(let n of e)if(n!==``){if(t.has(n))throw Error(`Duplicate header "${n}" after trimming — column names must be unique`);t.add(n)}}async function i(e,n={}){let{hasHeader:r=!0}=n,i=t(e),a=0,o=0;for await(let e of i){for(let t=0;t<e.length;t+=1)e[t]===10&&(a+=1);e.length>0&&(o=e[e.length-1]??0)}return o!==0&&o!==10&&(a+=1),r?Math.max(0,a-1):a}async function*a(e){let{filePath:i,delimiter:a=` `,hasHeader:o=!0,columns:s,relaxColumnCount:c=!1,quote:l=!1}=e,u=s?Array.from(s):o?e=>{let t=e.map(e=>e.trim());return r(t),t}:!1,d=t(i),f=d.pipe(n({delimiter:a,columns:u,bom:!0,skip_empty_lines:!0,relax_column_count:c,quote:l})),p=0;try{for await(let e of f){p+=1;let t={};if(Array.isArray(e)){let n=e;for(let e=0;e<n.length;e+=1)t[String(e)]=n[e]??``}else for(let[n,r]of Object.entries(e))t[n]=r??``;yield{rowNumber:p,row:t}}}finally{d.destroy()}}const o=e.object({format:e.enum([`csv`,`tsv`,`xlsx`]).optional(),delimiter:e.string().min(1).max(4).optional(),hasHeader:e.boolean().optional(),headerRowIndex:e.number().int().min(0).optional(),sheet:e.string().min(1).max(128).optional(),quote:e.union([e.string().length(1),e.literal(!1)]).optional()}).strict();function s(e){let t=e&&typeof e==`object`&&!Array.isArray(e)?e:{},n=t.feed===void 0||t.feed===null?{}:t.feed;return o.safeParse(n)}function c(e){let t=e.delimiter??(e.format===`csv`?`,`:` `);return{filePath:e.filePath,delimiter:t,...e.hasHeader!==void 0&&{hasHeader:e.hasHeader},...e.columns!==void 0&&{columns:e.columns},...e.relaxColumnCount!==void 0&&{relaxColumnCount:e.relaxColumnCount},...e.quote!==void 0&&{quote:e.quote}}}async function*l(e){if(e.format===`xlsx`){let{streamXlsx:t}=await import(`./xlsx-reader-D9Ntxm29.mjs`);yield*t({filePath:e.filePath,...e.hasHeader!==void 0&&{hasHeader:e.hasHeader},...e.headerRowIndex!==void 0&&{headerRowIndex:e.headerRowIndex},...e.sheet!==void 0&&{sheet:e.sheet}});return}yield*a(c(e))}export{i as a,r as i,l as n,a as o,s as r,o as t};
2
- //# sourceMappingURL=read-feed-DsLgRDNR.mjs.map
@@ -120,4 +120,3 @@ declare const IMPORTS_TOPICS: {
120
120
  type ImportsTopic = (typeof IMPORTS_TOPICS)[keyof typeof IMPORTS_TOPICS];
121
121
  //#endregion
122
122
  export { IMPORTS_TOPICS, type ImportsTopic, _warmRealtimePublisher, publishImportRunCreated, publishImportRunFailed, publishImportRunStarted, publishImportRunSucceeded };
123
- //# sourceMappingURL=index.d.mts.map
@@ -1,2 +1 @@
1
1
  import{n as e}from"./read-feed-DsLgRDNR.mjs";import{bulkPartialUpdate as t,bulkUpsert as n}from"@murumets-ee/search-elasticsearch";const r=50,i=5;var a=class{patterns=new Map;maxPatterns;maxSamplesPerPattern;droppedSignatures=0;constructor(e={}){this.maxPatterns=e.maxPatterns??50,this.maxSamplesPerPattern=e.maxSamplesPerPattern??5}addError(e,t,n,r,i){let a=`${t}:${r??`GENERAL`}:${n}`,o=this.patterns.get(a);if(!o){if(this.patterns.size>=this.maxPatterns){this.droppedSignatures+=1;return}o={errorType:t,field:r??null,message:n,count:0,firstOccurrence:e,lastOccurrence:e,samples:[]},this.patterns.set(a,o)}o.count+=1,o.lastOccurrence=e,o.samples.length<this.maxSamplesPerPattern&&o.samples.push({rowNumber:e,rowData:i})}getTotalErrorCount(){let e=0;for(let t of this.patterns.values())e+=t.count;return e}getDistinctPatternCount(){return this.patterns.size}getDroppedSignatureCount(){return this.droppedSignatures}getTopPatterns(){let e=Array.from(this.patterns.values()).sort((e,t)=>t.count-e.count),t=e.reduce((e,t)=>e+t.count,0);return e.map(e=>({errorType:e.errorType,field:e.field,message:e.message,count:e.count,firstOccurrence:e.firstOccurrence,lastOccurrence:e.lastOccurrence,samples:e.samples.slice(),percentage:t>0?e.count/t*100:0}))}snapshot(){return{totalErrors:this.getTotalErrorCount(),distinctPatterns:this.getDistinctPatternCount(),droppedSignatures:this.droppedSignatures,patterns:this.getTopPatterns()}}};function o(e,t){let n=e.totalErrors+t.totalErrors,r=e=>({...e,percentage:n>0?e.count/n*100:0});return{totalErrors:n,distinctPatterns:e.distinctPatterns+t.distinctPatterns,droppedSignatures:e.droppedSignatures+t.droppedSignatures,patterns:[...e.patterns.map(r),...t.patterns.map(r)]}}const s={count:0,nullRate:0,min:null,median:null,max:null},c=5e4;var l=class{count=0;nullCount=0;nonNullSeen=0;samples=[];record(e){if(this.count+=1,e===void 0){this.nullCount+=1;return}if(this.nonNullSeen+=1,this.samples.length<5e4){this.samples.push(e);return}let t=Math.floor(Math.random()*this.nonNullSeen);t<5e4&&(this.samples[t]=e)}snapshot(){if(this.count===0)return{...s};let e=this.nullCount/this.count;if(this.samples.length===0)return{count:this.count,nullRate:e,min:null,median:null,max:null};let t=[...this.samples].sort((e,t)=>e-t),n=t[0]??null,r=t[t.length-1]??null,i=Math.floor(t.length/2),a;if(t.length%2==0){let e=t[i-1],n=t[i];a=e!==void 0&&n!==void 0?(e+n)/2:null}else a=t[i]??null;return{count:this.count,nullRate:e,min:n,median:a,max:r}}};const u=10;function d(e,t){if(e.median===null||t.median===null||e.median<=0||t.median<=0)return null;let n=t.median/e.median;return n>=10||n<=1/10?{ratio:n,prevMedian:e.median,currMedian:t.median}:null}function f(e){if(typeof e!=`object`||!e)return null;let t=e;return typeof t.count!=`number`||typeof t.nullRate!=`number`?null:{count:t.count,nullRate:t.nullRate,min:typeof t.min==`number`?t.min:null,median:typeof t.median==`number`?t.median:null,max:typeof t.max==`number`?t.max:null}}const p=1e3;async function m(r){let{importRunId:i,runLabel:o,params:s,transform:c,feed:u,esClient:d,esIndex:f,batchSize:m=p,onProgress:h,rowLimit:g,signal:_,errorTracker:v=new a,warningTracker:y=new a,priceStats:b=new l,logger:x}=r;if(m<1)throw Error(`batchSize must be >= 1 (got ${m})`);let S=Date.now();x?.info({importRunId:i,runLabel:o,batchSize:m,esIndex:f},`imports.runner: started`);let C=0,w=0,T=0,E=0,D=0,O=0,k=[],A=[],j=[],M=async()=>{if(j.length===0)return;let e=j;j=[];let t=Date.now(),r;try{r=await n(d,{index:f,docs:e.map(({id:e,doc:t})=>({id:e,doc:t})),..._!==void 0&&{signal:_}})}catch(t){let n=_?.aborted??(t instanceof Error&&(t.name===`AbortError`||/abort/i.test(t.message)))?`aborted`:`bulk_request_failed`,r=t instanceof Error?t.message:String(t);for(let{rowNumber:t}of e)v.addError(t,n,r,void 0,null);throw T+=e.length,t}w+=r.succeeded,T+=r.failures.length,O+=1;let a=Date.now()-t,o=(Date.now()-S)/1e3;if(x?.info({importRunId:i,batch:O,flushMs:a,submitted:e.length,succeeded:r.succeeded,failed:r.failures.length,rowsRead:C,rowsPerSecond:o>0?Math.round(C/o):0},`imports.runner: batch flushed`),r.failures.length>0){let t=new Map(e.map(e=>[e.id,e.rowNumber]));for(let e of r.failures){let n=t.get(e.id)??-1;v.addError(n,e.type,e.reason,void 0,{id:e.id})}}let s=new Set(r.failures.map(e=>e.id));for(let t of e)if(!(!t.warnings||t.warnings.length===0)&&!s.has(t.id)){D+=1;for(let e of t.warnings)y.addError(t.rowNumber,e.warningType,e.message,e.field,t.rowData)}for(let t of e)!t.pairs||t.pairs.length===0||s.has(t.id)||k.push(...t.pairs);for(let t of e)s.has(t.id)||b.record(t.priceSample);if(h){let e=(Date.now()-S)/1e3;h({rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,rowsWarned:D,batchesCompleted:O,elapsedSeconds:e,rowsPerSecond:e>0?C/e:0,distinctErrorPatterns:v.getDistinctPatternCount()})}},N=m,P=new Map;for await(let{rowNumber:t,row:n}of e(u)){if(_?.aborted||g!==void 0&&C>=g)break;if(C+=1,C===1&&x?.info({importRunId:i,rowNumber:t,sampleKeys:Object.keys(n)},`imports.runner: STREAM_FIRST_ROW`),C%N===0){let e=(Date.now()-S)/1e3;x?.info({importRunId:i,rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,pending:j.length,elapsedSec:Math.round(e*10)/10,rowsPerSecond:e>0?Math.round(C/e):0,distinctFirstErrorTypes:P.size},`imports.runner: STREAM_TICK`)}let e={importRunId:i,params:s,runLabel:o,rowNumber:t},r;try{r=await c(n,e)}catch(e){let r=e instanceof Error?e.message:String(e);v.addError(t,`transform_threw`,r,void 0,n),P.has(`transform_threw`)||(P.set(`transform_threw`,{rowNumber:t,message:r}),x?.warn({importRunId:i,rowNumber:t,errorType:`transform_threw`,message:r},`imports.runner: FIRST_ERROR transform_threw`)),T+=1;continue}if(r.kind===`skip`){E+=1,r.patches&&r.patches.length>0&&A.push(...r.patches);continue}if(r.kind===`error`){v.addError(t,r.error.errorType,r.error.message,r.error.field,n),P.has(r.error.errorType)||(P.set(r.error.errorType,{rowNumber:t,message:r.error.message}),x?.warn({importRunId:i,rowNumber:t,errorType:r.error.errorType,field:r.error.field,message:r.error.message},`imports.runner: FIRST_ERROR ${r.error.errorType}`)),T+=1;continue}j.push({id:r.id,doc:r.doc,rowNumber:t,rowData:n,...r.warnings&&r.warnings.length>0&&{warnings:r.warnings},...r.pairs&&r.pairs.length>0&&{pairs:r.pairs},...r.priceSample!==void 0&&{priceSample:r.priceSample}}),j.length>=m&&await M()}await M();let F=0,I=0;for(let e=0;e<A.length;e+=m){if(_?.aborted)throw Error(`imports: run aborted during core-charge attach (phase 2)`);let n=A.slice(e,e+m),r;try{r=await t(d,{index:f,updates:n.map(({id:e,doc:t})=>({id:e,doc:t})),..._!==void 0&&{signal:_}})}catch(e){if(_?.aborted??(e instanceof Error&&(e.name===`AbortError`||/abort/i.test(e.message))))throw e;let t=e instanceof Error?e.message:String(e);for(let e of n)y.addError(-1,`patch_attach_failed`,t,e.orphanWarning?.field,{target:e.id});continue}if(F+=r.succeeded,r.failures.length>0){let e=new Map(n.map(e=>[e.id,e]));for(let t of r.failures){let n=e.get(t.id)?.orphanWarning;t.status===404||t.type===`document_missing_exception`?(I+=1,y.addError(-1,n?.warningType??`orphan_patch`,n?.message??t.reason,n?.field,{target:t.id})):y.addError(-1,`patch_attach_failed`,t.reason,n?.field,{target:t.id})}}}let L=(Date.now()-S)/1e3;return x?.info({importRunId:i,rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,batchesCompleted:O,patchesApplied:F,patchesOrphaned:I,totalSec:Math.round(L*10)/10,rowsPerSecond:L>0?Math.round(C/L):0},`imports.runner: finished`),{rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,rowsWarned:D,batchesCompleted:O,errors:v.snapshot(),warnings:y.snapshot(),pairs:k,patchesApplied:F,patchesOrphaned:I,priceStats:b.snapshot()}}export{c as a,f as c,a as d,o as f,u as i,r as l,m as n,l as o,s as r,d as s,p as t,i as u};
2
- //# sourceMappingURL=runner-BnFvID1z.mjs.map
@@ -1,2 +1 @@
1
1
  import{a as e,i as t,r as n}from"./parts-schema-QqLci_Pl.mjs";const r={name:`discount_table`,fields:[{key:`discount_group`,label:`Discount group`,required:!0,type:`string`},{key:`discount_pct`,label:`Discount %`,required:!0,type:`number`}],buildRow(n,r){let i=typeof n.discount_group==`string`?n.discount_group.trim():``;if(i===``)return e(`missing_discount_group`,`discount_group is empty`,`discount_group`);let a=t(n.discount_pct);return a.kind===`invalid`?e(`invalid_discount_pct`,`discount_pct is not a number`,`discount_pct`):a.kind===`absent`?e(`missing_discount_pct`,`discount_pct is empty`,`discount_pct`):a.value<0||a.value>100?e(`discount_pct_out_of_range`,`discount_pct ${a.value} is outside 0–100`,`discount_pct`):{kind:`success`,id:i,doc:{discountGroup:i,discountPct:a.value}}}},i={[n.name]:n,[r.name]:r};function a(e){return i[e]}function o(){return Object.keys(i)}function s(){return Object.values(i).map(e=>({name:e.name,fields:e.fields}))}export{r as i,a as n,o as r,s as t};
2
- //# sourceMappingURL=schema-registry-DB5aIZMN.mjs.map
@@ -29,4 +29,3 @@ import { r as FilePathResolver } from "./worker-CRH_PjKO.mjs";
29
29
  declare const storageResolveFilePath: FilePathResolver;
30
30
  //#endregion
31
31
  export { storageResolveFilePath };
32
- //# sourceMappingURL=storage-resolver.d.mts.map
@@ -1,2 +1 @@
1
1
  import{getImportsStorage as e}from"./storage.mjs";import{promises as t}from"node:fs";import{tmpdir as n}from"node:os";import{join as r}from"node:path";const i=async i=>{let a=await e(),o=await a.getAdapterLocalPath(i);if(o)return{localPath:o};let{body:s}=await a.download(i),c=Buffer.isBuffer(s)?s:Buffer.from(await new Response(s).arrayBuffer()),l=i.split(`/`).pop()??`feed`,u=r(n(),`imports-${crypto.randomUUID()}-${l}`);return await t.writeFile(u,c),{localPath:u,cleanup:async()=>{try{await t.unlink(u)}catch(e){if(e.code!==`ENOENT`)throw e}}}};export{i as storageResolveFilePath};
2
- //# sourceMappingURL=storage-resolver.mjs.map
@@ -28,4 +28,3 @@ declare function getImportsStorage(): Promise<StorageClient>;
28
28
  declare function _resetImportsStorageRegistration(): void;
29
29
  //#endregion
30
30
  export { ImportsStorageFactory, _resetImportsStorageRegistration, getImportsStorage, registerImportsStorageFactory };
31
- //# sourceMappingURL=storage.d.mts.map
package/dist/storage.mjs CHANGED
@@ -1,2 +1 @@
1
1
  const e=Symbol.for(`@murumets-ee/imports:storage-factory`),t=Symbol.for(`@murumets-ee/imports:storage-promise`);function n(e){return globalThis[e]}function r(e,t){globalThis[e]=t}function i(n){r(e,n),r(t,void 0)}function a(){let i=n(t);if(i)return i;let a=n(e),o=Promise.resolve(a?a():s()).catch(e=>{throw r(t,void 0),e});return r(t,o),o}function o(){r(e,void 0),r(t,void 0)}async function s(){let{createStorageClient:e}=await import(`@murumets-ee/storage`),{getStorageConfig:t}=await import(`@murumets-ee/storage/plugin`),{getApp:n}=await import(`@murumets-ee/core`);return e(t(),{app:n()})}export{o as _resetImportsStorageRegistration,a as getImportsStorage,i as registerImportsStorageFactory};
2
- //# sourceMappingURL=storage.mjs.map
@@ -1,2 +1 @@
1
1
  import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t,n}from"./engine-B0ExE4jL.mjs";import{n as r,r as i}from"./schema-registry-DB5aIZMN.mjs";import{behavior as a,defineEntity as o,field as s,searchable as c}from"@murumets-ee/entity";import{z as l}from"zod";import{sql as u}from"drizzle-orm";var d=e({IMPORT_RUN_STATUSES:()=>f,ImportRun:()=>p});const f=[`pending`,`running`,`succeeded`,`failed`,`cancelled`],p=o({name:`import_run`,fields:{label:s.text({required:!0,maxLength:255}),status:s.select({options:f,default:`pending`,indexed:!0}),filePath:s.text({required:!0,maxLength:2048}),transformName:s.text({required:!0,maxLength:128,indexed:!0}),params:s.json(),totals:s.json(),errorSummary:s.json(),warningSummary:s.json(),priceStats:s.json(),startedAt:s.date(),finishedAt:s.date(),queueJobId:s.text({maxLength:64,indexed:!0})},behaviors:[a.auditable(),c({fields:[`label`,`transformName`,`queueJobId`,`status`,`filePath`],projection:e=>({id:e.id,label:e.label,description:`${e.transformName} · ${e.status}`})})],scope:`global`,admin:{group:`imports`,label:`Import runs`,labelSingular:`Import run`,icon:`upload`,hideFromMenu:!0}}),m={decimal:`.`,thousands:``},h=l.array(n).max(512),g=l.strictObject({columns:l.array(l.string().min(1).max(256)).min(1).max(256),rows:l.array(l.record(l.string().max(500))).max(5)});function _(e){if(e.headerSnapshot!==void 0&&e.headerSnapshot!==null){let t=g.safeParse(e.headerSnapshot);if(!t.success)throw Error(`Invalid headerSnapshot: ${t.error.issues[0]?.message??`bad shape`}`);let n=JSON.stringify(e.headerSnapshot).length;if(n>1e5)throw Error(`headerSnapshot too large (${n} > 100000 serialized chars) — trim sample rows/cells`)}let n=typeof e.targetSchema==`string`?e.targetSchema:``,a=r(n);if(!a)throw Error(`Unknown target schema "${n}" (registered: ${i().join(`, `)})`);let o=t.safeParse(e.numberFormat);if(!o.success)throw Error(`Invalid numberFormat: ${o.error.issues[0]?.message??`bad shape`}`);let s=h.safeParse(e.columnMap);if(!s.success)throw Error(`Invalid columnMap: ${s.error.issues[0]?.message??`bad shape`}`);let c=JSON.stringify(e.columnMap).length;if(c>1e5)throw Error(`columnMap too large (${c} > 100000 serialized chars)`);let l=new Set(a.fields.map(e=>e.key)),u=new Set,d=new Set;for(let e of s.data){if(!l.has(e.target))throw Error(`Mapping target "${e.target}" is not a field of schema "${a.name}" (allowed: ${[...l].join(`, `)})`);u.has(e.target)&&d.add(e.target),u.add(e.target)}if(d.size>0)throw Error(`Mapping for schema "${a.name}" maps a target more than once: ${[...d].join(`, `)}`);let f=new Set(s.data.map(e=>e.target)),p=a.fields.filter(e=>e.required&&!f.has(e.key)).map(e=>e.key);if(p.length>0)throw Error(`Mapping for schema "${a.name}" is missing required target(s): ${p.join(`, `)}`)}const v={name:`imports:import-mapping-validation`,hooks:{beforeCreate:async e=>(_({targetSchema:e.targetSchema,numberFormat:e.numberFormat??m,columnMap:e.columnMap??[],headerSnapshot:e.headerSnapshot}),y(e)),beforeUpdate:async(e,t,n)=>{let r=await n?.loadCurrent?.()??{};return _({targetSchema:`targetSchema`in t?t.targetSchema:r.targetSchema,numberFormat:`numberFormat`in t?t.numberFormat:r.numberFormat??m,columnMap:`columnMap`in t?t.columnMap:r.columnMap??[],headerSnapshot:`headerSnapshot`in t?t.headerSnapshot:void 0}),y(t)}}};function y(e){return`quote`in e&&e.quote===``?{...e,quote:null}:e}const b=[`parts`,`discount_table`],x=[`csv`,`tsv`,`xlsx`],S=o({name:`import_mapping`,scope:`global`,fields:{supplierId:s.reference({entity:`supplier`,required:!0,indexed:!0}),brandId:s.reference({entity:`brand`,indexed:!0}),targetSchema:s.select({options:b,required:!0,indexed:!0}),name:s.text({required:!0,maxLength:255}),format:s.select({options:x,required:!0}),delimiter:s.text({maxLength:4}),hasHeader:s.boolean({default:!0}),headerRowIndex:s.number({integer:!0,min:0,default:0}),sheet:s.text({maxLength:128}),quote:s.text({maxLength:1}),numberFormat:s.json({default:{decimal:`.`,thousands:``}}),columnMap:s.json({default:[]}),headerSnapshot:s.json(),active:s.boolean({default:!0,indexed:!0})},behaviors:[a.auditable(),c({fields:[`name`,`targetSchema`,`format`],projection:e=>({id:e.id,label:e.name,description:`${e.targetSchema} · ${e.format}`})}),{name:`imports:import-mapping-unique`,constraints:(e,t)=>{if(t!==`main`)return[];let n=e.supplierId,r=e.brandId,i=e.targetSchema;return!n||!r||!i?[]:[{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_brand_schema`,on:[n,r,i],where:u`${r} IS NOT NULL`},{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_schema_default`,on:[n,i],where:u`${r} IS NULL`}]}},{name:`imports:import-mapping-quote`,constraints:(e,t)=>{if(t!==`main`)return[];let n=e.quote;return n?[{kind:`checkConstraint`,name:`ck_import_mapping_quote_single_char`,expression:u`${n} IS NULL OR length(${n}) = 1`}]:[]}},v],admin:{group:`imports`,label:`Import mappings`,labelSingular:`Import mapping`,icon:`table`,hideFromMenu:!0}});var C=class{entries=new Map;register(e,t){if(this.entries.has(e))throw Error(`Transform "${e}" is already registered — two plugins cannot register the same transform name.`);this.entries.set(e,t)}set(e,t){this.entries.set(e,t)}get(e){return this.entries.get(e)}has(e){return this.entries.has(e)}list(){return Array.from(this.entries.keys()).sort()}clear(){this.entries.clear()}};const w=Symbol.for(`@murumets-ee/imports:transforms`);function T(){let e=globalThis,t=e[w];return t||(t=new C,e[w]=t),t}function E(e,t){T().set(e,t)}export{b as a,v as c,d,x as i,f as l,T as n,S as o,E as r,_ as s,C as t,p as u};
2
- //# sourceMappingURL=transform-Cu5YEoOl.mjs.map
@@ -243,4 +243,3 @@ declare function getTransformRegistry(): TransformRegistry;
243
243
  declare function registerImportTransform<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void;
244
244
  //#endregion
245
245
  export { RowWarning as a, TransformRegistry as c, RowTransform as i, getTransformRegistry as l, RowError as n, TransformContext as o, RowResult as r, TransformName as s, DirectionalPair as t, registerImportTransform as u };
246
- //# sourceMappingURL=transform-WeiiLdYP.d.mts.map
@@ -937,4 +937,3 @@ interface ImportsRunDeadListenerConfig {
937
937
  declare function createImportsRunDeadListener(config: ImportsRunDeadListenerConfig): QueueTerminalNotifier;
938
938
  //#endregion
939
939
  export { ErrorSample as A, StreamFeedOptions as B, PriceStats as C, DEFAULT_MAX_PATTERNS as D, parsePriceStats as E, FeedReaderConfig as F, ImportRun as G, countDataRows as H, ReadFeedOptions as I, ImportRunClient as K, feedReaderConfigSchema as L, ErrorTrackerConfig as M, ErrorTrackerSnapshot as N, DEFAULT_MAX_SAMPLES_PER_PATTERN as O, mergeErrorTrackerSnapshots as P, readFeed as R, PriceAnomaly as S, detectPriceAnomaly as T, streamFeed as U, StreamFeedRow as V, IMPORT_RUN_STATUSES as W, RunImportResult as _, ImportsRunJobContext as a, PRICE_ANOMALY_THRESHOLD_MULTIPLIER as b, PairSinkContext as c, createRunImportHandler as d, importsRunJob as f, RunImportOptions as g, ImportRunProgress as h, ImportsRunDeadListenerConfig as i, ErrorTracker as j, ErrorPattern as k, RunImportHandlerConfig as l, DEFAULT_BATCH_SIZE as m, EsClientResolver as n, ImportsRunJobPayload as o, importsRunJobPayloadSchema as p, ImportRunStatus as q, FilePathResolver as r, PairSink as s, EnsureIndexResolver as t, createImportsRunDeadListener as u, runImport as v, PriceStatsCollector as w, PRICE_SAMPLE_CAP as x, EMPTY_PRICE_STATS as y, readPersistedFeedConfig as z };
940
- //# sourceMappingURL=worker-CRH_PjKO.d.mts.map
package/dist/worker.mjs CHANGED
@@ -1,2 +1 @@
1
1
  import{a as e,r as t}from"./read-feed-DsLgRDNR.mjs";import{c as n,d as r,f as i,n as a,s as o}from"./runner-BnFvID1z.mjs";import{a as s,i as c,r as l}from"./publish-DCXIPwTE.mjs";import{runAsCli as u}from"@murumets-ee/core";import{z as d}from"zod";import{and as f,desc as p,eq as m}from"drizzle-orm";import{defineJob as h}from"@murumets-ee/queue/client";const g=d.object({importRunId:d.string().uuid()}),_=h({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:g,defaultRetries:0});function v(l){let{importRuns:d,transforms:h,esClient:g,esIndex:_,resolveFilePath:v,ensureIndex:b,pairSink:x,logger:S}=l;return async l=>u(async()=>{let{importRunId:u}=l.payload,C=S?.child({jobId:l.id,importRunId:u,type:`imports:run`}),w=await d.findById(u);if(!w){C?.warn(`import_run row not found — skipping`);return}w.status===`running`&&C?.warn({previousQueueJobId:w.queueJobId,previousStartedAt:w.startedAt},`imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery`);let T=h.get(w.transformName);if(!T)throw await y(d,u,`Unknown transform "${w.transformName}"`),Error(`No transform registered for name "${w.transformName}"`);let E=w.params&&typeof w.params==`object`&&!Array.isArray(w.params)?w.params:{},D=t(E);if(!D.success){let e=D.error.issues[0]?.message??`invalid feed reader config`;throw await y(d,u,`Invalid params.feed: ${e}`),Error(`Invalid params.feed for import_run ${u}: ${e}`)}let O=D.data;await d.update(u,{status:`running`,queueJobId:l.id,startedAt:new Date}),c(u);let k;try{k=await g()}catch(e){throw await y(d,u,`Could not resolve ES client: ${e instanceof Error?e.message:String(e)}`),e}if(b)try{await b()}catch(e){throw await y(d,u,`Could not ensure ES index/alias: ${e instanceof Error?e.message:String(e)}`),e}let A,j;if(v)try{let e=await v(w.filePath);A=e.localPath,j=e.cleanup}catch(e){throw await y(d,u,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else A=w.filePath;let M;if(O.format!==`xlsx`)try{M=await e(A,O.hasHeader===void 0?{}:{hasHeader:O.hasHeader}),C?.info({importRunId:u,totalRows:M},`imports: pre-pass row count`)}catch(e){C?.warn({err:e},`imports: pre-pass row count failed — continuing without total`)}let N;try{let e={filePath:A,...O.format!==void 0&&{format:O.format},...O.delimiter!==void 0&&{delimiter:O.delimiter},...O.hasHeader!==void 0&&{hasHeader:O.hasHeader},...O.headerRowIndex!==void 0&&{headerRowIndex:O.headerRowIndex},...O.sheet!==void 0&&{sheet:O.sheet},...O.quote!==void 0&&{quote:O.quote}};N=await a({importRunId:u,runLabel:w.label,params:E,transform:T,feed:e,esClient:k,esIndex:_,onProgress:e=>l.updateProgress(M===void 0?e:{...e,totalRows:M}),...C!==void 0&&{logger:C}})}catch(e){throw await y(d,u,e instanceof Error?e.message:String(e)),j&&await j().catch(e=>{C?.warn({err:e},`filePath cleanup after failed run threw — ignoring`)}),e}j&&await j().catch(e=>{C?.warn({err:e},`filePath cleanup after successful run threw — ignoring`)});let P=N.warnings,F=typeof E.brandId==`string`?E.brandId:void 0,I=typeof E.supplierId==`string`?E.supplierId:void 0;if(F&&I&&N.priceStats.median!==null)try{let e=d.getTable(),t=await d.findMany({where:f(m(e.transformName,w.transformName),m(e.status,`succeeded`)),orderBy:p(e.createdAt),limit:50}),a=t.find(e=>{if(e.id===u)return!1;let t=e.params;if(!t||typeof t!=`object`||Array.isArray(t))return!1;let n=t;return n.brandId===F&&n.supplierId===I});!a&&t.length>=50&&C?.warn({brandId:F,supplierId:I,transformName:w.transformName},`imports: price-drift check found no prior run for this (brand, supplier) within the 50 most recent succeeded runs for this transform — a real prior run may exist beyond the bound, not necessarily absent`);let s=a?n(a.priceStats):null,c=s?o(s,N.priceStats):null;if(c){let e=c.ratio>=1,t=new r;t.addError(-1,`price_anomaly`,`Median purchase price is ${(e?c.ratio:1/c.ratio).toFixed(1)}x ${e?`higher`:`lower`} than the previous successful run for this supplier/brand (previous run ${a?.id??`unknown`}: median ${c.prevMedian}; this run: median ${c.currMedian}, minor units). Check for a decimal or thousands-separator format change in the source feed.`,`priceStats.median`,{previousImportRunId:a?.id??null,previousMedian:c.prevMedian,currentMedian:c.currMedian,ratio:c.ratio}),P=i(N.warnings,t.snapshot()),C?.warn({previousImportRunId:a?.id,ratio:c.ratio},`imports: price_anomaly — median purchase price shifted by a magnitude vs the prior run`)}}catch(e){C?.warn({err:e},`imports: price-drift comparison failed — continuing without it`)}if(await d.update(u,{status:`succeeded`,finishedAt:new Date,totals:{rowsRead:N.rowsRead,rowsSucceeded:N.rowsSucceeded,rowsFailed:N.rowsFailed,rowsSkipped:N.rowsSkipped,rowsWarned:N.rowsWarned,batchesCompleted:N.batchesCompleted,patchesApplied:N.patchesApplied,patchesOrphaned:N.patchesOrphaned},errorSummary:JSON.parse(JSON.stringify(N.errors)),warningSummary:JSON.parse(JSON.stringify(P)),priceStats:JSON.parse(JSON.stringify(N.priceStats))}),s(u,{rowsRead:N.rowsRead,rowsSucceeded:N.rowsSucceeded,rowsFailed:N.rowsFailed}),x&&N.pairs.length>0)try{await x(N.pairs,{importRunId:u,params:E,...C!==void 0&&{logger:C}}),C?.info({importRunId:u,pairs:N.pairs.length},`imports: pairSink invoked`)}catch(e){C?.warn({importRunId:u,pairs:N.pairs.length,err:e},`imports: pairSink threw — replacement ingestion incomplete, catalogue import unaffected`)}C?.info({rowsRead:N.rowsRead,rowsSucceeded:N.rowsSucceeded,rowsFailed:N.rowsFailed,rowsSkipped:N.rowsSkipped,batches:N.batchesCompleted},`import_run completed`)})}async function y(e,t,n){try{await e.update(t,{status:`failed`,finishedAt:new Date,errorSummary:{fatal:n}}),l(t,n)}catch{}}function b(e){let{importRuns:t,logger:n}=e;return{onJobDead:async e=>{e.jobType===`imports:run`&&await u(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),a=(await t.findMany({where:m(i.queueJobId,e.jobId),limit:1}))[0];if(!a){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(a.status!==`running`&&a.status!==`pending`){r?.info({importRunId:a.id,status:a.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}a.status===`pending`&&r?.warn({importRunId:a.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await y(t,a.id,e.error),r?.info({importRunId:a.id,attempts:e.attempts,priorStatus:a.status},`queue.job.dead → import_run marked failed`)})}}}export{b as createImportsRunDeadListener,v as createRunImportHandler,_ as importsRunJob,g as importsRunJobPayloadSchema};
2
- //# sourceMappingURL=worker.mjs.map
@@ -1,2 +1 @@
1
1
  import{i as e}from"./read-feed-DsLgRDNR.mjs";import{open as t}from"node:fs/promises";import{Duplex as n}from"node:stream";var r=class extends Error{headerRowNumber;constructor(e){super(`xlsx header row ${e} has no readable columns — the file may be corrupt, or its shared strings failed to load. Refusing rather than importing empty rows.`),this.name=`XlsxEmptyHeaderError`,this.headerRowNumber=e}},i=class extends Error{constructor(e){super(`xlsx path "${e}" is not a regular file`),this.name=`XlsxNotAFileError`}};function a(e){if(e==null)return``;if(typeof e==`string`)return e;if(typeof e==`number`||typeof e==`boolean`)return String(e);if(e instanceof Date)return e.toISOString();if(`richText`in e&&Array.isArray(e.richText))return e.richText.map(e=>e.text).join(``);if(`text`in e&&typeof e.text==`string`)return e.text;if(`result`in e&&e.result!==void 0&&e.result!==null){let t=e.result;return t instanceof Date?t.toISOString():typeof t==`object`?``:String(t)}return``}function o(e){let t=[];if(Array.isArray(e))for(let n=1;n<e.length;n+=1)t[n-1]=a(e[n]);else for(let[n,r]of Object.entries(e)){let e=Number.parseInt(n,10);!Number.isNaN(e)&&e>=1&&(t[e-1]=a(r))}for(let e=0;e<t.length;e+=1)t[e]===void 0&&(t[e]=``);return t}function s(e){let t=e.pipe.bind(e);e.pipe=(e,r)=>{if(e instanceof n){let t=e,n=t.emit.bind(t);t.emit=(e,...r)=>e===`end`&&t.readableLength>0?!1:n(e,...r)}return t(e,r)}}async function*c(n){let{filePath:a,hasHeader:c=!0,headerRowIndex:l=0,sheet:u}=n,d=await import(`exceljs`),f;if(u!==void 0&&(f=Number.parseInt(u,10),Number.isNaN(f)||f<1))throw Error(`Invalid xlsx sheet "${u}" — expected a 1-based worksheet index`);let p=await t(a,`r`),m;try{if(!(await p.stat()).isFile())throw new i(a);m=p.createReadStream({autoClose:!1}),s(m);let t=new d.stream.xlsx.WorkbookReader(m,{worksheets:`emit`,sharedStrings:`cache`,styles:`ignore`,hyperlinks:`ignore`,entries:`ignore`});t.model={sheets:[]};let n=0,u=0,h=!1,g=t[Symbol.asyncIterator](),_=!1;try{for(;;){let t=await g.next();if(t.done){_=!0;break}let i=t.value;if(u+=1,!(f===void 0?u===1:u===f)){for await(let e of i);continue}h=!0;let a=0,s=null;for await(let t of i){a+=1;let i=o(t.values);if(c&&s===null){if(a<l+1)continue;if(s=i.map(e=>e.trim()),s.every(e=>e===``))throw new r(a);e(s);continue}n+=1;let u={};if(s)for(let e=0;e<s.length;e+=1){let t=s[e];t&&(u[t]=i[e]??``)}else for(let e=0;e<i.length;e+=1)u[String(e)]=i[e]??``;yield{rowNumber:n,row:u}}break}}finally{try{for(;!_;){let e=await g.next();if(e.done)break;for await(let t of e.value);}}catch{}}if(f!==void 0&&!h)throw Error(`xlsx worksheet ${f} not found`)}finally{m?.destroy(),await p.close().catch(()=>{})}}export{r as XlsxEmptyHeaderError,i as XlsxNotAFileError,c as streamXlsx};
2
- //# sourceMappingURL=xlsx-reader-D9Ntxm29.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/imports",
3
- "version": "0.110.0",
3
+ "version": "0.112.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,13 +53,13 @@
53
53
  "drizzle-orm": "^0.45.2",
54
54
  "exceljs": "^4.4.0",
55
55
  "zod": "^3.24.1",
56
- "@murumets-ee/core": "0.110.0",
57
- "@murumets-ee/db": "0.110.0",
58
- "@murumets-ee/entity": "0.110.0",
59
- "@murumets-ee/logging": "0.110.0",
60
- "@murumets-ee/queue": "0.110.0",
61
- "@murumets-ee/search-elasticsearch": "0.110.0",
62
- "@murumets-ee/storage": "0.110.0"
56
+ "@murumets-ee/core": "0.112.0",
57
+ "@murumets-ee/db": "0.112.0",
58
+ "@murumets-ee/entity": "0.112.0",
59
+ "@murumets-ee/logging": "0.112.0",
60
+ "@murumets-ee/queue": "0.112.0",
61
+ "@murumets-ee/search-elasticsearch": "0.112.0",
62
+ "@murumets-ee/storage": "0.112.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/node": "^20.19.42",
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/analyze/dialect.ts","../../src/analyze/number-format.ts","../../src/analyze/analyze-feed.ts"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.mjs","names":["parseSync"],"sources":["../../src/analyze/dialect.ts","../../src/analyze/number-format.ts","../../src/analyze/analyze-feed.ts"],"sourcesContent":["/**\n * Decide how a delimited feed is actually laid out — delimiter, header row, and quoting —\n * from the file itself rather than from its extension or from a project default.\n *\n * ## Why all three are per-file\n *\n * The three ground-truth supplier feeds (R027) disagree on two of these axes at once, and each\n * disagreement is load-bearing:\n *\n * - Two are TAB-delimited `.txt`, one is comma-delimited `.csv`. An extension gets this right\n * only because these suppliers happen to be tidy; `.txt` says nothing.\n * - The CSV is genuine RFC 4180 — 1,476 of its rows carry a comma inside a quoted description\n * (`1023031,\"STEUN, DEMPER\",809.41,0,1201134`) — while the two TAB feeds use `\"` as ordinary\n * text and carry 2,321 lines with an ODD number of them (`\"O\"-RING`, `RACCORDO A \"T`).\n * Each file HARD-FAILS under the other's quoting: the CSV dies at row 11,126 of 808,147 with\n * `CSV_RECORD_INCONSISTENT_COLUMNS`, the TSV at row 1,476 of 865,612 with\n * `INVALID_OPENING_QUOTE`.\n *\n * ## How the quoting decision is made: by trying it, not by pattern-matching\n *\n * Quoting is settled by TRIAL PARSE over the whole file (or `maxRows` of it) using the same\n * reader the run uses, once per candidate. A heuristic over raw bytes — \"count the quotes, look\n * for odd lines\" — is cheaper and wrong: RFC 4180 permits a newline inside a quoted field, so\n * an odd count per physical line is evidence of nothing in a file that legitimately has them.\n * Running the real parser and seeing whether it completes is both simpler to justify and\n * exactly the question that matters.\n *\n * The common case costs one pass: a file with no `\"` in it at all is not ambiguous, and the\n * first trial already establishes that while it counts rows.\n */\n\nimport { parse as parseSync } from 'csv-parse/sync'\nimport { readFeed } from '../read-feed.js'\n\n/** Delimiters worth trying. Ordered by how often they appear in supplier feeds. */\nexport const CANDIDATE_DELIMITERS = ['\\t', ',', ';', '|'] as const\nexport type CandidateDelimiter = (typeof CANDIDATE_DELIMITERS)[number]\n\n/** How many bytes of the head of the file the cheap sample-based passes read. */\nexport const DIALECT_SAMPLE_BYTES = 512 * 1024\n/** How many records the sample-based passes consider. */\nexport const DIALECT_SAMPLE_ROWS = 200\n\nexport interface DelimiterInference {\n delimiter: CandidateDelimiter\n /** Field count the winning delimiter produces on the sample. */\n fieldCount: number\n /** Fraction of sampled rows that produced exactly `fieldCount` fields. */\n consistency: number\n evidence: string\n}\n\n/** Printable name for a delimiter, so error text does not contain a raw control character. */\nexport function delimiterName(d: string): string {\n if (d === '\\t') return 'TAB'\n if (d === ' ') return 'SPACE'\n return `\"${d}\"`\n}\n\n/**\n * Pick the delimiter that splits the sample into the most fields MOST CONSISTENTLY.\n *\n * Consistency is what does the work. A tab-delimited feed whose descriptions contain commas\n * still splits into a stable 15 columns on TAB, while the same rows split into 1, 2 or 3 on\n * comma depending on the description — so comma scores near zero even though it \"matches\"\n * plenty of lines. Field count breaks ties in favour of the delimiter that actually structures\n * the file rather than one that happens to appear in every row.\n *\n * Parsed with quoting OFF, deliberately: at this stage the quoting policy is not known, and\n * quoting cannot change which character separates fields — only how many fields a handful of\n * rows produce. Strictness is off too, since an inconsistent count is the measurement here\n * rather than an error.\n */\nexport function inferDelimiter(sample: string): DelimiterInference | null {\n let best: DelimiterInference | null = null\n for (const delimiter of CANDIDATE_DELIMITERS) {\n let rows: string[][]\n try {\n rows = parseSync(sample, {\n delimiter,\n columns: false,\n bom: true,\n skip_empty_lines: true,\n relax_column_count: true,\n quote: false,\n to_line: DIALECT_SAMPLE_ROWS,\n }) as string[][]\n } catch {\n // A delimiter this file does not use can still produce a parser error (a stray quote\n // where a field boundary lands differently). That is a vote against it, not a fault.\n continue\n }\n if (rows.length === 0) continue\n\n const counts = new Map<number, number>()\n for (const r of rows) counts.set(r.length, (counts.get(r.length) ?? 0) + 1)\n let modeCount = 0\n let modeFreq = 0\n for (const [fields, freq] of counts) {\n if (freq > modeFreq || (freq === modeFreq && fields > modeCount)) {\n modeCount = fields\n modeFreq = freq\n }\n }\n if (modeCount < 2) continue\n\n const consistency = modeFreq / rows.length\n // Consistency dominates; field count only separates two EQUALLY consistent readings — and\n // that is expressed as an ordered comparison rather than a weighted sum.\n //\n // `consistency * 1000 + modeCount` says the same thing only while `modeCount < 1000`. Above\n // that the field count starts outranking consistency, inverting the rule the docblock\n // states: a delimiter splitting a line into 4,000 low-consistency fields would beat one\n // splitting it into 15 with perfect consistency. That is not hypothetical for this module —\n // `DEFAULT_SAMPLES_TOTAL` exists in this same file because a mis-detected delimiter\n // collapsing a file into thousands of columns is the input it is written to survive.\n //\n // Two axes compared in order cannot be traded off against each other by accident. A magic\n // multiplier is a bound you have to remember; an ordered comparison is one you cannot forget.\n if (\n best === null ||\n consistency > best.consistency ||\n (consistency === best.consistency && modeCount > best.fieldCount)\n ) {\n best = {\n delimiter,\n fieldCount: modeCount,\n consistency,\n evidence:\n `${delimiterName(delimiter)} yields ${modeCount} fields on ` +\n `${Math.round(consistency * 100)}% of ${rows.length} sampled row(s)`,\n }\n }\n }\n return best\n}\n\nexport interface HeaderInference {\n hasHeader: boolean\n /** The first record's cells, trimmed — the header candidate, whatever the verdict. */\n firstRowCells: string[]\n evidence: string\n}\n\n/**\n * Decide whether the first record is a header.\n *\n * Two independent signals, either sufficient:\n *\n * 1. **The matcher recognises it.** If `matchesTargets` finds at least one target field for\n * the first row's cells, they are column NAMES — a data row of part codes and prices does\n * not match `article name` or `currency cd`. This is the strong signal and it is the one\n * that fires on all three real feeds.\n * 2. **A type change down the column.** The first row is all non-numeric and distinct, while\n * at least one column below it is predominantly numeric. That is the shape of a header over\n * a price column, and it catches feeds whose headers we have no alias for.\n *\n * Both are stated in the evidence string so an operator can see which one carried the verdict.\n */\nexport function inferHasHeader(\n records: readonly string[][],\n matchesTargets: (cells: readonly string[]) => number,\n): HeaderInference {\n const first = records[0] ?? []\n const firstRowCells = first.map((c) => c.trim())\n if (records.length === 0) {\n return { hasHeader: false, firstRowCells, evidence: 'the file has no records' }\n }\n\n const matched = matchesTargets(firstRowCells)\n if (matched > 0) {\n return {\n hasHeader: true,\n firstRowCells,\n evidence: `the first row names ${matched} recognisable target field(s)`,\n }\n }\n\n const nonEmpty = firstRowCells.filter((c) => c !== '')\n const allDistinct = new Set(nonEmpty).size === nonEmpty.length\n const numericRe = /^-?\\d+(?:[., ]\\d+)*$/\n const headerLooksTextual = nonEmpty.length > 0 && !firstRowCells.some((c) => numericRe.test(c))\n\n const body = records.slice(1)\n let numericColumn = false\n if (body.length > 0) {\n for (let col = 0; col < firstRowCells.length; col += 1) {\n let numeric = 0\n let seen = 0\n for (const row of body) {\n const cell = row[col]?.trim()\n if (cell === undefined || cell === '') continue\n seen += 1\n if (numericRe.test(cell)) numeric += 1\n }\n if (seen >= 3 && numeric / seen >= 0.8) {\n numericColumn = true\n break\n }\n }\n }\n\n if (headerLooksTextual && allDistinct && numericColumn) {\n return {\n hasHeader: true,\n firstRowCells,\n evidence:\n 'the first row is entirely non-numeric with distinct values while a column below it is ' +\n 'predominantly numeric',\n }\n }\n\n return {\n hasHeader: false,\n firstRowCells,\n evidence:\n 'the first row matches no target field and does not read as a header over the rows below ' +\n '— treating the feed as positional',\n }\n}\n\n/** Parse the first `DIALECT_SAMPLE_ROWS` records of a sample positionally, for header detection. */\nexport function sampleRecords(sample: string, delimiter: string): string[][] {\n try {\n return parseSync(sample, {\n delimiter,\n columns: false,\n bom: true,\n skip_empty_lines: true,\n relax_column_count: true,\n quote: false,\n to_line: DIALECT_SAMPLE_ROWS,\n }) as string[][]\n } catch {\n // Sampling is best-effort: the caller already has a delimiter verdict and can proceed\n // without header detection rather than failing the whole analysis on a head-of-file quirk.\n return []\n }\n}\n\n/** Outcome of parsing the whole feed under one candidate quoting policy. */\nexport interface TrialParse {\n quote: string | false\n ok: boolean\n /** Parser message when `ok` is false. */\n error: string | null\n /** Data rows successfully read before completion or failure. */\n rows: number\n /**\n * True when any cell contained a `\"`. Only meaningful on the `quote: false` trial, where the\n * reader preserves quote characters verbatim; under RFC 4180 the parser consumes the\n * structural ones, so their absence there proves nothing. This is the flag that decides\n * whether a second trial is needed at all.\n */\n sawQuoteChar: boolean\n /**\n * Sampled cell values per column, capped — the input to number-format inference.\n *\n * COMPACTED: empty cells are skipped, so these arrays are NOT row-aligned with each other and\n * must never be zipped by index into \"rows\". Use {@link TrialParse.sampleRows} for anything\n * that has to show a row. (Zipping them is exactly how the header snapshot came to attribute\n * one part's barcode to the part above it.)\n */\n samples: Map<string, string[]>\n /**\n * The first few WHOLE data rows, verbatim — for the header snapshot the wizard renders as its\n * sample table. Kept separately from `samples` precisely because that one is compacted: this\n * is the operator's verification surface, and a fabricated row on it is worse than no sample.\n */\n sampleRows: Array<Record<string, string>>\n /** Column names as the reader derived them. */\n columns: string[]\n}\n\nexport interface TrialParseOptions {\n filePath: string\n format: 'csv' | 'tsv'\n delimiter: string\n hasHeader: boolean\n quote: string | false\n /** Stop after this many data rows. `0`/absent = read the whole file. */\n maxRows?: number\n /** Cap on retained sample values PER COLUMN. */\n maxSamplesPerColumn?: number\n /** Cap on the TOTAL retained sample values, across all columns. */\n maxSamplesTotal?: number\n /** Cap on retained whole rows. */\n maxSampleRows?: number\n}\n\nconst DEFAULT_SAMPLES_PER_COLUMN = 2_000\n/**\n * Global ceiling on retained sample strings.\n *\n * The per-column cap alone bounds nothing in the column dimension: a 4,000-column feed would\n * retain 8M strings per trial, and both trials are live at once. Number-format inference converges\n * on a few thousand values regardless of how they are distributed, so a total cap costs the\n * inference nothing and makes the bound real.\n */\nconst DEFAULT_SAMPLES_TOTAL = 20_000\n/** Matches `SNAPSHOT_MAX_ROWS` in `../matching/header-snapshot.ts` — the wizard shows 5. */\nconst DEFAULT_SAMPLE_ROWS = 5\n\n/**\n * Parse the feed exactly as a run would, under one quoting policy, and report what happened.\n *\n * `readFeed` is used rather than a private parser so that \"this analysis says the file parses\"\n * and \"the import parses this file\" are the same claim. Strictness stays at the reader's\n * default (`relaxColumnCount` unset → false), which is what turns a wrong quoting policy into a\n * loud error at a named line instead of a quietly shifted column.\n */\nexport async function trialParse(options: TrialParseOptions): Promise<TrialParse> {\n const { filePath, format, delimiter, hasHeader, quote, maxRows } = options\n const perColumn = options.maxSamplesPerColumn ?? DEFAULT_SAMPLES_PER_COLUMN\n const total = options.maxSamplesTotal ?? DEFAULT_SAMPLES_TOTAL\n const rowCap = options.maxSampleRows ?? DEFAULT_SAMPLE_ROWS\n\n const samples = new Map<string, string[]>()\n const sampleRows: Array<Record<string, string>> = []\n let columns: string[] = []\n let rows = 0\n let retained = 0\n let sawQuoteChar = false\n\n try {\n for await (const { row } of readFeed({ filePath, format, delimiter, hasHeader, quote })) {\n rows += 1\n if (columns.length === 0) {\n columns = Object.keys(row)\n // A quote in a HEADER cell counts too. Under `hasHeader: true` the reader consumes the\n // header into column names, so scanning only values would report \"no quote character\n // appears anywhere\" about a file whose headers are quoted — and skip the second trial\n // on that basis.\n if (!sawQuoteChar && columns.some((c) => c.includes('\"'))) sawQuoteChar = true\n }\n if (sampleRows.length < rowCap) sampleRows.push({ ...row })\n for (const [col, value] of Object.entries(row)) {\n if (value === '') continue\n if (!sawQuoteChar && value.includes('\"')) sawQuoteChar = true\n if (retained >= total) continue\n const bucket = samples.get(col)\n if (bucket === undefined) {\n samples.set(col, [value])\n retained += 1\n } else if (bucket.length < perColumn) {\n bucket.push(value)\n retained += 1\n }\n }\n if (maxRows !== undefined && maxRows > 0 && rows >= maxRows) break\n }\n } catch (err) {\n return {\n quote,\n ok: false,\n error: err instanceof Error ? err.message : String(err),\n rows,\n sawQuoteChar,\n samples,\n sampleRows,\n columns,\n }\n }\n\n return { quote, ok: true, error: null, rows, sawQuoteChar, samples, sampleRows, columns }\n}\n","/**\n * Infer a feed's `{ decimal, thousands }` from its own sample values.\n *\n * ## Why this has to exist, and why it must be allowed to refuse\n *\n * `number-format.ts` states the package's standing position outright: *\"The mapping stores an\n * explicit `{ decimal, thousands }` per profile so there is no guessing — `parseNumber` trusts\n * the configured format.\"* That is the right position for the RUN path and nothing here\n * changes it. What it leaves unanswered is where the operator's answer comes from, and until\n * now the answer was \"a dropdown in the wizard, set by hand, per feed\".\n *\n * Getting it wrong is not a parse error. `326,88` read as dot-decimal with comma-thousands\n * parses cleanly to `32688` — a 100× price, with no warning, on a row that imports\n * successfully. Measured on the real feeds (R027), two of the three suppliers use a comma\n * decimal (`1,35`, `348,86`) and the third uses a dot (`0.67`, `809.41`), so a single default\n * is wrong for someone no matter which one is picked.\n *\n * ## The rule: decide on evidence, and say so when there is none\n *\n * A separator is a DECIMAL when it is followed by a run of digits whose length is not 3\n * (`1,35` → 2 digits; `809.41` → 2; `0.1` → 1; `1,2345` → 4). It is a GROUPING separator when\n * it appears more than once in one value (`1.234.567`) or when a value contains two different\n * separators, in which case the LAST one is the decimal and the other is the grouping.\n *\n * A separator followed by exactly 3 digits and nothing else — `1,234` — is the one case that\n * carries no information at all: it reads equally as one-thousand-two-hundred-thirty-four and\n * as one-point-two-three-four. When a file's evidence is only of that shape, this returns\n * `ambiguous` and names the values, because the alternative is a silent 1000× error. The\n * operator states the format; nothing is inferred from where the supplier is registered.\n *\n * The chosen format is then validated by running the REAL {@link parseNumber} over every\n * sample — one parser, not two, so a format this module blesses cannot be one the run rejects.\n */\n\nimport { type NumberFormat, parseNumber } from '../mapping/number-format.js'\n\n/** Separator characters a feed may plausibly use. Space covers `1 234,56` (fr/nb grouping). */\nconst SEPARATORS = ['.', ',', ' '] as const\ntype Separator = (typeof SEPARATORS)[number]\n\n/**\n * A value that is only digits and separators (plus an optional leading sign). Anything else —\n * `NA`, `EUR 12,00`, a part code — is not a number and contributes no evidence rather than\n * counting as malformed.\n */\nconst NUMERIC_SHAPE_RE = /^-?\\d+(?:[., ]\\d+)*$/\n\n/** How confident the inference is, and therefore whether a caller may act on it unattended. */\nexport type NumberFormatConfidence =\n /** Direct evidence, no contradiction. */\n | 'high'\n /** No separated values at all — every sample was a bare integer, so any format parses them. */\n | 'no-evidence'\n /** Every separated value has exactly 3 trailing digits: `1,234` is 1234 or 1.234 and nothing distinguishes them. */\n | 'ambiguous'\n /** The file contains values that argue for BOTH decimal separators. Mixed conventions in one feed. */\n | 'conflict'\n\nexport interface NumberFormatInference {\n /**\n * The inferred format. Present for every confidence EXCEPT `ambiguous` / `conflict`, where\n * there is deliberately no answer — a caller must obtain one from the operator.\n */\n format: NumberFormat | null\n confidence: NumberFormatConfidence\n /** Human-readable statement of what was measured, for the operator to audit the decision. */\n evidence: string\n /**\n * Sample values that the inferred format does NOT parse. Empty on a clean inference. These\n * are not necessarily a defect — a feed with junk rows is normal and `ErrorTracker` exists\n * for it — but a long list means the inference is probably wrong.\n */\n unparsed: string[]\n /** How many sample values were numeric-shaped, and how many were considered at all. */\n counts: { considered: number; numeric: number; separated: number }\n}\n\ninterface Evidence {\n decimal: Record<Separator, number>\n grouping: Record<Separator, number>\n ambiguous: Record<Separator, number>\n ambiguousSamples: string[]\n}\n\nfunction emptyEvidence(): Evidence {\n return {\n decimal: { '.': 0, ',': 0, ' ': 0 },\n grouping: { '.': 0, ',': 0, ' ': 0 },\n ambiguous: { '.': 0, ',': 0, ' ': 0 },\n ambiguousSamples: [],\n }\n}\n\nfunction isSeparator(ch: string): ch is Separator {\n return ch === '.' || ch === ',' || ch === ' '\n}\n\n/** Positions of every separator character in a numeric-shaped value, in order. */\nfunction separatorPositions(value: string): Array<{ char: Separator; index: number }> {\n const out: Array<{ char: Separator; index: number }> = []\n for (let i = 0; i < value.length; i += 1) {\n const ch = value[i]\n if (ch !== undefined && isSeparator(ch)) out.push({ char: ch, index: i })\n }\n return out\n}\n\n/**\n * Accumulate what one value proves about the file's convention.\n *\n * Note the two-separator branch does not care WHICH characters they are: `1.234,56` and\n * `1,234.56` and `1 234,56` are all \"the last separator is the decimal\". That is the whole\n * rule, and it is why this needs no locale table.\n */\nfunction recordValue(value: string, ev: Evidence): void {\n const seps = separatorPositions(value)\n if (seps.length === 0) return\n\n const distinct = new Set(seps.map((s) => s.char))\n if (distinct.size >= 2) {\n const last = seps[seps.length - 1]\n if (last === undefined) return\n ev.decimal[last.char] += 1\n for (const s of seps) if (s.char !== last.char) ev.grouping[s.char] += 1\n return\n }\n\n const char = seps[0]?.char\n if (char === undefined) return\n if (seps.length >= 2) {\n // `1.234.567` — a decimal separator cannot repeat.\n ev.grouping[char] += 1\n return\n }\n const trailing = value.length - (seps[0]?.index ?? 0) - 1\n if (trailing === 3) {\n ev.ambiguous[char] += 1\n if (ev.ambiguousSamples.length < 5) ev.ambiguousSamples.push(value)\n return\n }\n ev.decimal[char] += 1\n}\n\n/**\n * Infer the number format from sample cell values.\n *\n * Pass every non-empty cell from the columns that map to `type: 'number'` targets. Cells that\n * are not numeric-shaped (`NA`, blank, a description) are skipped rather than held against the\n * inference — `GrossPrice` is the literal string `NA` on all 865,612 rows of one real feed, and\n * a rule that treated that as malformed would learn nothing from that supplier at all.\n */\nexport function inferNumberFormat(samples: readonly string[]): NumberFormatInference {\n const ev = emptyEvidence()\n let numeric = 0\n const numericValues: string[] = []\n for (const raw of samples) {\n const v = raw.trim()\n if (v === '') continue\n if (!NUMERIC_SHAPE_RE.test(v)) continue\n numeric += 1\n numericValues.push(v)\n recordValue(v, ev)\n }\n\n // A space is never a decimal separator in ANY convention, so neither its \"decimal\" evidence\n // nor its \"ambiguous\" evidence is a real reading — both are artefacts of a rule written for\n // `.` and `,`. Fold both into grouping before deciding.\n //\n // The ambiguous half was the bug: `recordValue` routes a single separator with exactly three\n // trailing digits to `ambiguous` before the character is considered, so `1 234` landed there\n // and a file of `['1 234', '5 678']` came back `ambiguous` with `format: null` — refusing to\n // decide something that has only one reading. Worse, the refusal text quoted `\"1,234\"` and\n // claimed two readings for values that have one.\n ev.grouping[' '] += ev.decimal[' '] + ev.ambiguous[' ']\n ev.decimal[' '] = 0\n ev.ambiguous[' '] = 0\n\n // Counted AFTER the fold, so `grouped` in the evidence string includes the space values it\n // just absorbed rather than reporting a figure from before they moved.\n const separated = ev.decimal['.'] + ev.decimal[',']\n const grouped = ev.grouping['.'] + ev.grouping[','] + ev.grouping[' ']\n const ambiguousTotal = ev.ambiguous['.'] + ev.ambiguous[',']\n const counts = { considered: samples.length, numeric, separated: separated + grouped }\n\n const dotDec = ev.decimal['.']\n const commaDec = ev.decimal[',']\n\n if (dotDec > 0 && commaDec > 0) {\n return {\n format: null,\n confidence: 'conflict',\n evidence:\n `the file argues for BOTH decimal separators — ${dotDec} value(s) put a non-3-digit run ` +\n `after \".\" and ${commaDec} after \",\". One feed is using two conventions, or the ` +\n `delimiter is wrong and two columns have been merged into one.`,\n unparsed: [],\n counts,\n }\n }\n\n let decimal: '.' | ',' | null = null\n if (dotDec > 0) decimal = '.'\n else if (commaDec > 0) decimal = ','\n\n if (decimal === null) {\n if (ambiguousTotal > 0) {\n const chars = SEPARATORS.filter((c) => ev.ambiguous[c] > 0).map((c) => `\"${c}\"`)\n return {\n format: null,\n confidence: 'ambiguous',\n evidence:\n `every separated value has EXACTLY 3 digits after its separator (${chars.join('/')}), ` +\n `e.g. ${ev.ambiguousSamples.join(', ')}. \"1,234\" is one-thousand-two-hundred-thirty-four ` +\n `or one-point-two-three-four and this file contains nothing that distinguishes them. ` +\n `Guessing here is a silent 1000x error, so state the format explicitly.`,\n unparsed: [],\n counts,\n }\n }\n // Grouping IS known, the decimal is not — `1 234` with no decimal anywhere. Report the\n // grouping that was measured and default only the part that was not, rather than reporting\n // \"no evidence\" about a file that supplied some. The decimal default must differ from the\n // chosen thousands (`parseNumber` strips thousands first, so equal separators would strip\n // the decimal too), which is what picks `,` when the grouping is `.`.\n const groupingOnly = SEPARATORS.find((c) => ev.grouping[c] > 0)\n if (groupingOnly !== undefined) {\n const impliedDecimal: '.' | ',' = groupingOnly === '.' ? ',' : '.'\n return {\n format: { decimal: impliedDecimal, thousands: groupingOnly },\n confidence: 'no-evidence',\n evidence:\n `thousands \"${groupingOnly === ' ' ? ' ' : groupingOnly}\" from ${grouped} grouped ` +\n `value(s), but NO value shows a decimal separator, so the decimal is a default ` +\n `(\"${impliedDecimal}\", chosen to differ from the grouping) rather than a reading. ` +\n `Check it against the supplier's own documentation before saving.`,\n unparsed: [],\n counts,\n }\n }\n\n // Every sample was a bare integer. Any format parses them identically today, but the\n // choice is still recorded on the mapping and will apply to tomorrow's file.\n const fallback: NumberFormat = { decimal: '.', thousands: '' }\n return {\n format: fallback,\n confidence: 'no-evidence',\n evidence:\n numeric === 0\n ? 'no numeric-shaped values were found in the sampled columns.'\n : `all ${numeric} numeric sample(s) were bare integers with no separator, so the file ` +\n `says nothing about its decimal separator. Defaulted to \".\" / none.`,\n unparsed: [],\n counts,\n }\n }\n\n const groupingCandidates = SEPARATORS.filter((c) => c !== decimal && ev.grouping[c] > 0)\n const thousands: NumberFormat['thousands'] = groupingCandidates[0] ?? ''\n if (groupingCandidates.length > 1) {\n return {\n format: null,\n confidence: 'conflict',\n evidence:\n `decimal is \"${decimal}\", but ${groupingCandidates.length} different characters ` +\n `(${groupingCandidates.map((c) => `\"${c}\"`).join(', ')}) are each used as a thousands ` +\n `separator. A feed uses one.`,\n unparsed: [],\n counts,\n }\n }\n\n const format: NumberFormat = { decimal, thousands }\n // Validate against the REAL parser rather than against this module's own reasoning: if the\n // two ever disagree, the run is what ships, so the run is what decides.\n const unparsed: string[] = []\n for (const v of numericValues) {\n if (Number.isNaN(parseNumber(v, format))) {\n if (unparsed.length < 5) unparsed.push(v)\n }\n }\n\n return {\n format,\n confidence: 'high',\n evidence:\n `decimal \"${decimal}\" from ${decimal === '.' ? dotDec : commaDec} value(s) with a ` +\n `non-3-digit run after it; thousands ${thousands === '' ? 'none' : `\"${thousands}\"`} ` +\n `from ${grouped} grouped value(s). ${numeric} numeric sample(s) considered.`,\n unparsed,\n counts,\n }\n}\n","/**\n * Analyse a supplier feed and propose the `import_mapping` it needs — read-only.\n *\n * This is the thing `lumi imports:analyze` is. It writes nothing, touches no database, and\n * returns a document that `lumi imports:mapping:create` turns into a row. Splitting it that way\n * is the point: the operator reads the proposal, corrects the parts a machine cannot know, and\n * only then does anything get written.\n *\n * ## What it decides, and what it refuses to decide\n *\n * DECIDES, from the file: delimiter, whether row 0 is a header, the quoting policy, and the\n * `{ decimal, thousands }` number format — each by measurement, each per file, because the real\n * feeds disagree on every one of them (R027).\n *\n * REFUSES, on principle: any column it cannot match to a target field. Those are reported by\n * NAME in `unmapped`, never dropped and never guessed at. Two reasons, and the second is the\n * one that matters:\n *\n * 1. A silently dropped column is a silently missing price, barcode or description, and the\n * import succeeds either way.\n * 2. Some columns are ambiguous in a way no amount of cleverness resolves. `superseded no` is\n * the canonical one: TecDoc reads that phrasing as the OLD code (the row is current), while\n * `ersetzt durch` / `replaced by` mean the NEW one (the row is obsolete). Both are\n * \"replacement\" words and only grammatical voice separates them. `parts-schema.ts`\n * deliberately aliases those spellings to NEITHER target so an operator has to state the\n * direction — and getting it backwards builds well-formed chains with valid heads, no\n * warnings, and no repair, because the merge is append-only. This module upholds that\n * refusal rather than working around it, and says WHY in the reason text.\n */\n\nimport type { MappingColumn } from '../mapping/engine.js'\nimport type { NumberFormat } from '../mapping/number-format.js'\nimport { getTargetSchema, listTargetSchemas } from '../mapping/schema-registry.js'\nimport type { TargetField } from '../mapping/target-schema.js'\nimport { type HeaderSample, suggestTargets, toHeaderSnapshot } from '../matching/index.js'\nimport {\n DIALECT_SAMPLE_BYTES,\n delimiterName,\n inferDelimiter,\n inferHasHeader,\n sampleRecords,\n type TrialParse,\n trialParse,\n} from './dialect.js'\nimport { inferNumberFormat, type NumberFormatConfidence } from './number-format.js'\n\n/**\n * Document version. `imports:mapping:create` refuses a document it does not understand rather\n * than reading fields positionally out of a shape that has moved.\n */\nexport const FEED_ANALYSIS_VERSION = 1\n\n/**\n * Header spellings that name a supersession relationship WITHOUT stating its direction — see the\n * module docblock.\n *\n * `ersatzteilnummer` is deliberately NOT here, and the near-miss is the point: it looks like\n * `ersatznummer` plus three letters and means something entirely different — *Ersatz**teil**nummer*\n * is the part's own OE identification number, not a replacement code at all. `parts-schema.ts`\n * calls it a false friend and `auto-assign.test.ts` asserts it resolves to neither supersession\n * target. Listing it here would have made `imports:analyze` hand the operator the direction\n * question about an OE number column and offer both chain targets as the fix — prompting them\n * into precisely the silent, unrepairable mistake the prompt exists to prevent.\n */\nconst AMBIGUOUS_DIRECTION_HEADERS = new Set([\n 'supersededno',\n 'supersededarticle',\n 'supersedednumber',\n 'ersatznummer',\n 'ersatznr',\n 'replacementcode',\n 'replacementno',\n])\n\n/** The two targets an ambiguous-direction column must choose between. */\nconst DIRECTION_TARGETS = ['superseding_code', 'replaces_code'] as const\n\nexport interface UnmappedColumn {\n source: string\n /** Why it was not assigned, in words an operator can act on. */\n reason: string\n /**\n * Targets the operator should choose BETWEEN, when the column is recognisably one of a known\n * ambiguous pair. Empty when nothing plausible matched at all.\n */\n candidates: string[]\n}\n\nexport interface FeedAnalysisReader {\n format: 'csv' | 'tsv'\n /** Always explicit, so the document states what was measured rather than implying a default. */\n delimiter: string\n hasHeader: boolean\n /** `false` = quotes are ordinary data; `'\"'` = RFC 4180. */\n quote: string | false\n}\n\nexport interface FeedAnalysis {\n version: typeof FEED_ANALYSIS_VERSION\n file: { path: string; bytes: number }\n targetSchema: string\n reader: FeedAnalysisReader\n numberFormat: NumberFormat | null\n numberFormatConfidence: NumberFormatConfidence\n /** Proposed source→target assignments, with the wizard's own default transform chains. */\n columnMap: MappingColumn[]\n /** Every source column with no assignment, each with a reason. Never silently dropped. */\n unmapped: UnmappedColumn[]\n /** Sample header + rows, shaped and capped exactly as the wizard's save does (#667). */\n headerSnapshot: HeaderSample | null\n /** Data rows the trial parse read. Exact when the whole file was scanned. */\n dataRows: number\n /** True when `maxRows` cut the scan short, so `dataRows` is a floor rather than a count. */\n truncated: boolean\n /** What was measured, per decision, for the operator to audit rather than trust. */\n evidence: {\n delimiter: string\n hasHeader: string\n quote: string\n numberFormat: string\n }\n /** Non-fatal things the operator should read before saving. */\n warnings: string[]\n}\n\nexport class FeedAnalysisError extends Error {\n readonly hint: string | undefined\n constructor(message: string, hint?: string) {\n super(message)\n this.name = 'FeedAnalysisError'\n this.hint = hint\n }\n}\n\nexport interface AnalyzeFeedOptions {\n filePath: string\n /** Registered target schema name. Defaults to `parts`. */\n targetSchema?: string\n /** Stop scanning after this many data rows. `0`/absent = the whole file. */\n maxRows?: number\n /** Injected for tests; defaults to reading the head of `filePath`. */\n readSample?: (filePath: string, bytes: number) => Promise<string>\n /** Injected for tests; defaults to `fs.stat`. */\n fileSize?: (filePath: string) => Promise<number>\n}\n\n/** Per-target-type default transform chain — mirrors the wizard's `defaultTransforms`. */\nfunction defaultTransforms(type: TargetField['type']): MappingColumn['transforms'] {\n return type === 'number' ? [{ op: 'trim' }, { op: 'number' }] : [{ op: 'trim' }]\n}\n\n/** Bare-alphanumeric form, matching the matcher's own normalisation. */\nfunction normalize(s: string): string {\n return s.toLowerCase().replace(/[^a-z0-9]/g, '')\n}\n\nasync function defaultReadSample(filePath: string, bytes: number): Promise<string> {\n const { open } = await import('node:fs/promises')\n const handle = await open(filePath, 'r')\n try {\n const buf = Buffer.alloc(bytes)\n const { bytesRead } = await handle.read(buf, 0, bytes, 0)\n return buf.subarray(0, bytesRead).toString('utf8')\n } finally {\n await handle.close()\n }\n}\n\nasync function defaultFileSize(filePath: string): Promise<number> {\n const { stat } = await import('node:fs/promises')\n return (await stat(filePath)).size\n}\n\n/**\n * Choose the quoting policy by trying both and seeing which one the real reader survives.\n *\n * The three outcomes and what each means:\n * - only one trial completes → that is the policy, and the other's parser error is the proof;\n * - neither completes → the delimiter or header verdict is wrong, and reporting both errors\n * is more useful than picking the one that got further;\n * - both complete AND the file contains `\"` → genuinely ambiguous. RFC 4180 is preferred,\n * because a file whose every quote sits at a field boundary is far likelier to be quoted\n * than to coincidentally contain balanced quote characters — but the operator is told, since\n * the two readings differ in the VALUES they produce even where they agree on the columns.\n */\nfunction chooseQuoting(\n plain: TrialParse,\n quoted: TrialParse | null,\n): { chosen: TrialParse; evidence: string; warning: string | null } {\n if (quoted === null) {\n return {\n chosen: plain,\n evidence: `no quote character appears anywhere in ${plain.rows} scanned row(s), so quoting is immaterial; parsed ${plain.rows} row(s) cleanly with quotes as data`,\n warning: null,\n }\n }\n if (plain.ok && !quoted.ok) {\n return {\n chosen: plain,\n evidence: `quotes are ordinary data: parsed ${plain.rows} row(s) cleanly, while RFC 4180 failed after ${quoted.rows} row(s) — ${quoted.error}`,\n warning: null,\n }\n }\n if (!plain.ok && quoted.ok) {\n return {\n chosen: quoted,\n evidence: `RFC 4180 quoting: parsed ${quoted.rows} row(s) cleanly, while treating quotes as data failed after ${plain.rows} row(s) — ${plain.error}`,\n warning: null,\n }\n }\n if (plain.ok && quoted.ok) {\n return {\n chosen: quoted,\n evidence: `BOTH policies parse this file cleanly (${plain.rows} row(s) each); chose RFC 4180 because every quote character sits at a field boundary`,\n warning:\n 'Quoting is ambiguous — both policies parse this file. RFC 4180 was chosen, which STRIPS ' +\n 'the enclosing quotes from field values; treating them as data would keep them. Check a ' +\n 'sample row before saving, and set the policy explicitly if the other reading is right.',\n }\n }\n throw new FeedAnalysisError(\n `neither quoting policy can parse this file.\\n` +\n ` quotes-as-data: ${plain.error} (after ${plain.rows} row(s))\\n` +\n ` RFC 4180: ${quoted.error} (after ${quoted.rows} row(s))`,\n 'Both failures usually mean the DELIMITER or the header verdict is wrong, not the quoting. ' +\n 'Check the reported delimiter against the file, and re-run with an explicit --delimiter.',\n )\n}\n\n/**\n * Analyse a delimited supplier feed. Reads the file; writes nothing.\n *\n * xlsx is deliberately out of scope: none of its four decisions apply (a sheet has no delimiter,\n * no quoting and no text number format — exceljs hands back typed cells), so an \"analysis\" of one\n * would be four constants and a column list. Map those in the wizard.\n */\nexport async function analyzeFeed(options: AnalyzeFeedOptions): Promise<FeedAnalysis> {\n const { filePath } = options\n const schemaName = options.targetSchema ?? 'parts'\n const schema = getTargetSchema(schemaName)\n if (schema === undefined) {\n throw new FeedAnalysisError(\n `Unknown target schema \"${schemaName}\".`,\n `Registered schemas: ${listTargetSchemas().join(', ')}`,\n )\n }\n const readSample = options.readSample ?? defaultReadSample\n const fileSize = options.fileSize ?? defaultFileSize\n\n const bytes = await fileSize(filePath)\n const sample = await readSample(filePath, DIALECT_SAMPLE_BYTES)\n if (sample.trim() === '') {\n throw new FeedAnalysisError(`${filePath} is empty.`)\n }\n\n // ── 1. Delimiter ────────────────────────────────────────────────────────────────────────\n const delimiterInference = inferDelimiter(sample)\n if (delimiterInference === null) {\n throw new FeedAnalysisError(\n `Could not determine a field delimiter for ${filePath}.`,\n 'No candidate (TAB, comma, semicolon, pipe) split the sample into more than one column ' +\n 'consistently. If the feed uses another delimiter, or is a spreadsheet, map it in the ' +\n 'admin wizard instead.',\n )\n }\n const { delimiter } = delimiterInference\n const format: 'csv' | 'tsv' = delimiter === ',' ? 'csv' : 'tsv'\n\n // ── 2. Header ───────────────────────────────────────────────────────────────────────────\n const records = sampleRecords(sample, delimiter)\n const headerInference = inferHasHeader(\n records,\n (cells) =>\n suggestTargets(\n cells.filter((c) => c !== ''),\n schema.fields,\n ).size,\n )\n const { hasHeader } = headerInference\n\n // ── 3. Quoting — by trial parse, using the reader the run uses ──────────────────────────\n const base = {\n filePath,\n format,\n delimiter,\n hasHeader,\n ...(options.maxRows !== undefined && { maxRows: options.maxRows }),\n }\n const plain = await trialParse({ ...base, quote: false })\n // A second pass is only worth its cost when the file actually contains quote characters, or\n // when the first pass failed and RFC 4180 is the remaining explanation.\n const needsQuotedTrial = plain.sawQuoteChar || !plain.ok\n const quoted = needsQuotedTrial ? await trialParse({ ...base, quote: '\"' }) : null\n const { chosen, evidence: quoteEvidence, warning: quoteWarning } = chooseQuoting(plain, quoted)\n\n const warnings: string[] = []\n if (quoteWarning !== null) warnings.push(quoteWarning)\n\n const columns = chosen.columns\n if (columns.length === 0) {\n throw new FeedAnalysisError(\n `${filePath} parsed to zero columns.`,\n 'The file may be empty below its header.',\n )\n }\n\n // ── 4. Column map ───────────────────────────────────────────────────────────────────────\n const assigned = suggestTargets(columns, schema.fields)\n const fieldsByKey = new Map(schema.fields.map((f) => [f.key, f]))\n const columnMap: MappingColumn[] = []\n const unmapped: UnmappedColumn[] = []\n for (const source of columns) {\n const target = assigned.get(source)\n if (target !== undefined) {\n const field = fieldsByKey.get(target)\n columnMap.push({\n source,\n target,\n transforms: defaultTransforms(field?.type ?? 'string'),\n })\n continue\n }\n unmapped.push(describeUnmapped(source))\n }\n\n // ── 5. Number format, sampled from the columns that feed number targets ─────────────────\n const numberSources = columnMap\n .filter((c) => fieldsByKey.get(c.target)?.type === 'number')\n .map((c) => c.source)\n const numberSamples: string[] = []\n for (const source of numberSources) {\n for (const v of chosen.samples.get(source) ?? []) numberSamples.push(v)\n }\n const nf = inferNumberFormat(numberSamples)\n if (nf.format === null) {\n // The reason already sits in `evidence.numberFormat`, but that field is an AUDIT surface —\n // something a careful operator reads to check a decision. A refusal is different: it is a\n // thing the caller must act on, and a programmatic consumer of this document (the CLI is\n // one, a future admin route another) reads `warnings`. Leaving the only statement of it in\n // the audit surface makes `numberFormat: null` the sole signal, which reads as \"not\n // applicable\" rather than \"you must supply this\".\n warnings.push(\n `The number format could NOT be decided (${nf.confidence}): ${nf.evidence} ` +\n `Supply { decimal, thousands } explicitly before saving.`,\n )\n }\n if (nf.unparsed.length > 0) {\n warnings.push(\n `${nf.unparsed.length}+ sampled value(s) in the number columns do not parse under the ` +\n `inferred format: ${nf.unparsed.join(', ')}. Those rows will be row errors at import.`,\n )\n }\n if (numberSources.length === 0) {\n warnings.push(\n 'No column mapped to a numeric target, so the number format was inferred from nothing. ' +\n 'Check it before saving.',\n )\n }\n\n // ── 6. Header snapshot, shaped exactly as a wizard save would shape it (#667) ───────────\n //\n // From `sampleRows` — the WHOLE rows the trial retained — and emphatically NOT by zipping\n // `chosen.samples` by index. That map is compacted (empty cells are skipped), so its columns\n // are not row-aligned: zipping them produced a snapshot where a part carried the barcode of\n // the part BELOW it, every value plausible and every pairing invented. This snapshot is the\n // table the wizard renders for the operator to map against, so a fabricated row there is\n // worse than no sample at all.\n const headerSnapshot = hasHeader\n ? toHeaderSnapshot({ columns: [...columns], rows: chosen.sampleRows })\n : null\n\n return {\n version: FEED_ANALYSIS_VERSION,\n file: { path: filePath, bytes },\n targetSchema: schemaName,\n reader: { format, delimiter, hasHeader, quote: chosen.quote },\n numberFormat: nf.format,\n numberFormatConfidence: nf.confidence,\n columnMap,\n unmapped,\n headerSnapshot,\n dataRows: chosen.rows,\n truncated:\n options.maxRows !== undefined && options.maxRows > 0 && chosen.rows >= options.maxRows,\n evidence: {\n delimiter: delimiterInference.evidence,\n hasHeader: headerInference.evidence,\n quote: quoteEvidence,\n numberFormat: nf.evidence,\n },\n warnings,\n }\n}\n\n/**\n * Explain why a column was not assigned — naming the choice when the column is one of the known\n * direction-ambiguous spellings, so the operator gets the actual question rather than a shrug.\n */\nfunction describeUnmapped(source: string): UnmappedColumn {\n if (AMBIGUOUS_DIRECTION_HEADERS.has(normalize(source))) {\n return {\n source,\n reason:\n `\"${source}\" names a replacement code but not its DIRECTION, and the two readings are ` +\n `opposites: the column may hold the NEWER code that supersedes this row ` +\n `(superseding_code), or the OLDER code this row supersedes (replaces_code). This is ` +\n `deliberately not guessed — an inverted feed builds well-formed chains with valid heads ` +\n `and no warnings, and the merge is append-only, so re-importing the right way round does ` +\n `NOT repair it. Check a superseded code against the supplier's current catalogue and ` +\n `state the direction.`,\n candidates: [...DIRECTION_TARGETS],\n }\n }\n return {\n source,\n reason: `no target field matches \"${source}\".`,\n candidates: [],\n }\n}\n\n/** Every source column the analysis did not assign — the set a caller must resolve explicitly. */\nexport function unmappedSources(analysis: FeedAnalysis): string[] {\n return analysis.unmapped.map((u) => u.source)\n}\n\n/** Printable delimiter, re-exported so a CLI does not have to re-derive it. */\nexport { delimiterName }\n"],"mappings":"2OAmCA,MAAa,EAAuB,CAAC,IAAM,IAAK,IAAK,GAAG,EAI3C,EAAuB,IAAM,KAE7B,EAAsB,IAYnC,SAAgB,EAAc,EAAmB,CAG/C,OAFI,IAAM,IAAa,MACnB,IAAM,IAAY,QACf,IAAI,EAAE,EACf,CAgBA,SAAgB,EAAe,EAA2C,CACxE,IAAI,EAAkC,KACtC,IAAK,IAAM,KAAa,EAAsB,CAC5C,IAAI,EACJ,GAAI,CACF,EAAOA,EAAU,EAAQ,CACvB,YACA,QAAS,GACT,IAAK,GACL,iBAAkB,GAClB,mBAAoB,GACpB,MAAO,GACP,QAAA,GACF,CAAC,CACH,MAAQ,CAGN,QACF,CACA,GAAI,EAAK,SAAW,EAAG,SAEvB,IAAM,EAAS,IAAI,IACnB,IAAK,IAAM,KAAK,EAAM,EAAO,IAAI,EAAE,QAAS,EAAO,IAAI,EAAE,MAAM,GAAK,GAAK,CAAC,EAC1E,IAAI,EAAY,EACZ,EAAW,EACf,IAAK,GAAM,CAAC,EAAQ,KAAS,GACvB,EAAO,GAAa,IAAS,GAAY,EAAS,KACpD,EAAY,EACZ,EAAW,GAGf,GAAI,EAAY,EAAG,SAEnB,IAAM,EAAc,EAAW,EAAK,QAclC,IAAS,MACT,EAAc,EAAK,aAClB,IAAgB,EAAK,aAAe,EAAY,EAAK,cAEtD,EAAO,CACL,YACA,WAAY,EACZ,cACA,SACE,GAAG,EAAc,CAAS,EAAE,UAAU,EAAU,aAC7C,KAAK,MAAM,EAAc,GAAG,EAAE,OAAO,EAAK,OAAO,gBACxD,EAEJ,CACA,OAAO,CACT,CAwBA,SAAgB,EACd,EACA,EACiB,CAEjB,IAAM,GADQ,EAAQ,IAAM,CAAC,EAAA,CACD,IAAK,GAAM,EAAE,KAAK,CAAC,EAC/C,GAAI,EAAQ,SAAW,EACrB,MAAO,CAAE,UAAW,GAAO,gBAAe,SAAU,yBAA0B,EAGhF,IAAM,EAAU,EAAe,CAAa,EAC5C,GAAI,EAAU,EACZ,MAAO,CACL,UAAW,GACX,gBACA,SAAU,uBAAuB,EAAQ,8BAC3C,EAGF,IAAM,EAAW,EAAc,OAAQ,GAAM,IAAM,EAAE,EAC/C,EAAc,IAAI,IAAI,CAAQ,CAAC,CAAC,OAAS,EAAS,OAClD,EAAY,uBACZ,EAAqB,EAAS,OAAS,GAAK,CAAC,EAAc,KAAM,GAAM,EAAU,KAAK,CAAC,CAAC,EAExF,EAAO,EAAQ,MAAM,CAAC,EACxB,EAAgB,GACpB,GAAI,EAAK,OAAS,EAChB,IAAK,IAAI,EAAM,EAAG,EAAM,EAAc,OAAQ,GAAO,EAAG,CACtD,IAAI,EAAU,EACV,EAAO,EACX,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAO,EAAI,EAAI,EAAE,KAAK,EACxB,IAAS,IAAA,IAAa,IAAS,KACnC,GAAQ,EACJ,EAAU,KAAK,CAAI,IAAG,GAAW,GACvC,CACA,GAAI,GAAQ,GAAK,EAAU,GAAQ,GAAK,CACtC,EAAgB,GAChB,KACF,CACF,CAaF,OAVI,GAAsB,GAAe,EAChC,CACL,UAAW,GACX,gBACA,SACE,6GAEJ,EAGK,CACL,UAAW,GACX,gBACA,SACE,2HAEJ,CACF,CAGA,SAAgB,EAAc,EAAgB,EAA+B,CAC3E,GAAI,CACF,OAAOA,EAAU,EAAQ,CACvB,YACA,QAAS,GACT,IAAK,GACL,iBAAkB,GAClB,mBAAoB,GACpB,MAAO,GACP,QAAA,GACF,CAAC,CACH,MAAQ,CAGN,MAAO,CAAC,CACV,CACF,CAyEA,eAAsB,EAAW,EAAiD,CAChF,GAAM,CAAE,WAAU,SAAQ,YAAW,YAAW,QAAO,WAAY,EAC7D,EAAY,EAAQ,qBAAuB,IAC3C,EAAQ,EAAQ,iBAAmB,IACnC,EAAS,EAAQ,eAAiB,EAElC,EAAU,IAAI,IACd,EAA4C,CAAC,EAC/C,EAAoB,CAAC,EACrB,EAAO,EACP,EAAW,EACX,EAAe,GAEnB,GAAI,CACF,UAAW,GAAM,CAAE,SAAS,EAAS,CAAE,WAAU,SAAQ,YAAW,YAAW,OAAM,CAAC,EAAG,CACvF,GAAQ,EACJ,EAAQ,SAAW,IACrB,EAAU,OAAO,KAAK,CAAG,EAKrB,CAAC,GAAgB,EAAQ,KAAM,GAAM,EAAE,SAAS,GAAG,CAAC,IAAG,EAAe,KAExE,EAAW,OAAS,GAAQ,EAAW,KAAK,CAAE,GAAG,CAAI,CAAC,EAC1D,IAAK,GAAM,CAAC,EAAK,KAAU,OAAO,QAAQ,CAAG,EAAG,CAG9C,GAFI,IAAU,KACV,CAAC,GAAgB,EAAM,SAAS,GAAG,IAAG,EAAe,IACrD,GAAY,GAAO,SACvB,IAAM,EAAS,EAAQ,IAAI,CAAG,EAC1B,IAAW,IAAA,IACb,EAAQ,IAAI,EAAK,CAAC,CAAK,CAAC,EACxB,GAAY,GACH,EAAO,OAAS,IACzB,EAAO,KAAK,CAAK,EACjB,GAAY,EAEhB,CACA,GAAI,IAAY,IAAA,IAAa,EAAU,GAAK,GAAQ,EAAS,KAC/D,CACF,OAAS,EAAK,CACZ,MAAO,CACL,QACA,GAAI,GACJ,MAAO,aAAe,MAAQ,EAAI,QAAU,OAAO,CAAG,EACtD,OACA,eACA,UACA,aACA,SACF,CACF,CAEA,MAAO,CAAE,QAAO,GAAI,GAAM,MAAO,KAAM,OAAM,eAAc,UAAS,aAAY,SAAQ,CAC1F,CCxUA,MAAM,EAAa,CAAC,IAAK,IAAK,GAAG,EAQ3B,EAAmB,uBAuCzB,SAAS,GAA0B,CACjC,MAAO,CACL,QAAS,CAAE,IAAK,EAAG,IAAK,EAAG,IAAK,CAAE,EAClC,SAAU,CAAE,IAAK,EAAG,IAAK,EAAG,IAAK,CAAE,EACnC,UAAW,CAAE,IAAK,EAAG,IAAK,EAAG,IAAK,CAAE,EACpC,iBAAkB,CAAC,CACrB,CACF,CAEA,SAAS,EAAY,EAA6B,CAChD,OAAO,IAAO,KAAO,IAAO,KAAO,IAAO,GAC5C,CAGA,SAAS,EAAmB,EAA0D,CACpF,IAAM,EAAiD,CAAC,EACxD,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,OAAQ,GAAK,EAAG,CACxC,IAAM,EAAK,EAAM,GACb,IAAO,IAAA,IAAa,EAAY,CAAE,GAAG,EAAI,KAAK,CAAE,KAAM,EAAI,MAAO,CAAE,CAAC,CAC1E,CACA,OAAO,CACT,CASA,SAAS,EAAY,EAAe,EAAoB,CACtD,IAAM,EAAO,EAAmB,CAAK,EACrC,GAAI,EAAK,SAAW,EAAG,OAGvB,GAAI,IADiB,IAAI,EAAK,IAAK,GAAM,EAAE,IAAI,CACpC,CAAC,CAAC,MAAQ,EAAG,CACtB,IAAM,EAAO,EAAK,EAAK,OAAS,GAChC,GAAI,IAAS,IAAA,GAAW,OACxB,EAAG,QAAQ,EAAK,OAAS,EACzB,IAAK,IAAM,KAAK,EAAU,EAAE,OAAS,EAAK,OAAM,EAAG,SAAS,EAAE,OAAS,GACvE,MACF,CAEA,IAAM,EAAO,EAAK,EAAE,EAAE,KAClB,OAAS,IAAA,GACb,IAAI,EAAK,QAAU,EAAG,CAEpB,EAAG,SAAS,IAAS,EACrB,MACF,CAEA,GADiB,EAAM,QAAU,EAAK,EAAE,EAAE,OAAS,GAAK,GACvC,EAAG,CAClB,EAAG,UAAU,IAAS,EAClB,EAAG,iBAAiB,OAAS,GAAG,EAAG,iBAAiB,KAAK,CAAK,EAClE,MACF,CACA,EAAG,QAAQ,IAAS,CAPpB,CAQF,CAUA,SAAgB,EAAkB,EAAmD,CACnF,IAAM,EAAK,EAAc,EACrB,EAAU,EACR,EAA0B,CAAC,EACjC,IAAK,IAAM,KAAO,EAAS,CACzB,IAAM,EAAI,EAAI,KAAK,EACf,IAAM,IACL,EAAiB,KAAK,CAAC,IAC5B,GAAW,EACX,EAAc,KAAK,CAAC,EACpB,EAAY,EAAG,CAAE,EACnB,CAWA,EAAG,SAAS,MAAQ,EAAG,QAAQ,KAAO,EAAG,UAAU,KACnD,EAAG,QAAQ,KAAO,EAClB,EAAG,UAAU,KAAO,EAIpB,IAAM,EAAY,EAAG,QAAQ,KAAO,EAAG,QAAQ,KACzC,EAAU,EAAG,SAAS,KAAO,EAAG,SAAS,KAAO,EAAG,SAAS,KAC5D,EAAiB,EAAG,UAAU,KAAO,EAAG,UAAU,KAClD,EAAS,CAAE,WAAY,EAAQ,OAAQ,UAAS,UAAW,EAAY,CAAQ,EAE/E,EAAS,EAAG,QAAQ,KACpB,EAAW,EAAG,QAAQ,KAE5B,GAAI,EAAS,GAAK,EAAW,EAC3B,MAAO,CACL,OAAQ,KACR,WAAY,WACZ,SACE,iDAAiD,EAAO,gDACvC,EAAS,qHAE5B,SAAU,CAAC,EACX,QACF,EAGF,IAAI,EAA4B,KAIhC,GAHI,EAAS,EAAG,EAAU,IACjB,EAAW,IAAG,EAAU,KAE7B,IAAY,KAAM,CACpB,GAAI,EAAiB,EAEnB,MAAO,CACL,OAAQ,KACR,WAAY,YACZ,SACE,mEALU,EAAW,OAAQ,GAAM,EAAG,UAAU,GAAK,CAAC,CAAC,CAAC,IAAK,GAAM,IAAI,EAAE,EAKF,CAAC,CAAC,KAAK,GAAG,EAAE,UAC3E,EAAG,iBAAiB,KAAK,IAAI,EAAE,8MAGzC,SAAU,CAAC,EACX,QACF,EAOF,IAAM,EAAe,EAAW,KAAM,GAAM,EAAG,SAAS,GAAK,CAAC,EAC9D,GAAI,IAAiB,IAAA,GAAW,CAC9B,IAAM,EAA4B,IAAiB,IAAM,IAAM,IAC/D,MAAO,CACL,OAAQ,CAAE,QAAS,EAAgB,UAAW,CAAa,EAC3D,WAAY,cACZ,SACE,cAAc,IAAiB,IAAM,IAAM,EAAa,SAAS,EAAQ,2FAEpE,EAAe,gIAEtB,SAAU,CAAC,EACX,QACF,CACF,CAKA,MAAO,CACL,OAAQ,CAFuB,QAAS,IAAK,UAAW,EAEzC,EACf,WAAY,cACZ,SACE,IAAY,EACR,8DACA,OAAO,EAAQ,yIAErB,SAAU,CAAC,EACX,QACF,CACF,CAEA,IAAM,EAAqB,EAAW,OAAQ,GAAM,IAAM,GAAW,EAAG,SAAS,GAAK,CAAC,EACjF,EAAuC,EAAmB,IAAM,GACtE,GAAI,EAAmB,OAAS,EAC9B,MAAO,CACL,OAAQ,KACR,WAAY,WACZ,SACE,eAAe,EAAQ,SAAS,EAAmB,OAAO,yBACtD,EAAmB,IAAK,GAAM,IAAI,EAAE,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,4DAEzD,SAAU,CAAC,EACX,QACF,EAGF,IAAM,EAAuB,CAAE,UAAS,WAAU,EAG5C,EAAqB,CAAC,EAC5B,IAAK,IAAM,KAAK,EACV,OAAO,MAAM,EAAY,EAAG,CAAM,CAAC,GACjC,EAAS,OAAS,GAAG,EAAS,KAAK,CAAC,EAI5C,MAAO,CACL,SACA,WAAY,OACZ,SACE,YAAY,EAAQ,SAAS,IAAY,IAAM,EAAS,EAAS,uDAC1B,IAAc,GAAK,OAAS,IAAI,EAAU,GAAG,QAC5E,EAAQ,qBAAqB,EAAQ,gCAC/C,WACA,QACF,CACF,CCjPA,MAAa,EAAwB,EAc/B,EAA8B,IAAI,IAAI,CAC1C,eACA,oBACA,mBACA,eACA,WACA,kBACA,eACF,CAAC,EAGK,EAAoB,CAAC,mBAAoB,eAAe,EAkD9D,IAAa,EAAb,cAAuC,KAAM,CAC3C,KACA,YAAY,EAAiB,EAAe,CAC1C,MAAM,CAAO,EACb,KAAK,KAAO,oBACZ,KAAK,KAAO,CACd,CACF,EAeA,SAAS,EAAkB,EAAwD,CACjF,OAAO,IAAS,SAAW,CAAC,CAAE,GAAI,MAAO,EAAG,CAAE,GAAI,QAAS,CAAC,EAAI,CAAC,CAAE,GAAI,MAAO,CAAC,CACjF,CAGA,SAAS,EAAU,EAAmB,CACpC,OAAO,EAAE,YAAY,CAAC,CAAC,QAAQ,aAAc,EAAE,CACjD,CAEA,eAAe,EAAkB,EAAkB,EAAgC,CACjF,GAAM,CAAE,QAAS,MAAM,OAAO,oBACxB,EAAS,MAAM,EAAK,EAAU,GAAG,EACvC,GAAI,CACF,IAAM,EAAM,OAAO,MAAM,CAAK,EACxB,CAAE,aAAc,MAAM,EAAO,KAAK,EAAK,EAAG,EAAO,CAAC,EACxD,OAAO,EAAI,SAAS,EAAG,CAAS,CAAC,CAAC,SAAS,MAAM,CACnD,QAAU,CACR,MAAM,EAAO,MAAM,CACrB,CACF,CAEA,eAAe,EAAgB,EAAmC,CAChE,GAAM,CAAE,QAAS,MAAM,OAAO,oBAC9B,OAAQ,MAAM,EAAK,CAAQ,EAAA,CAAG,IAChC,CAcA,SAAS,EACP,EACA,EACkE,CAClE,GAAI,IAAW,KACb,MAAO,CACL,OAAQ,EACR,SAAU,0CAA0C,EAAM,KAAK,oDAAoD,EAAM,KAAK,qCAC9H,QAAS,IACX,EAEF,GAAI,EAAM,IAAM,CAAC,EAAO,GACtB,MAAO,CACL,OAAQ,EACR,SAAU,oCAAoC,EAAM,KAAK,+CAA+C,EAAO,KAAK,YAAY,EAAO,QACvI,QAAS,IACX,EAEF,GAAI,CAAC,EAAM,IAAM,EAAO,GACtB,MAAO,CACL,OAAQ,EACR,SAAU,4BAA4B,EAAO,KAAK,8DAA8D,EAAM,KAAK,YAAY,EAAM,QAC7I,QAAS,IACX,EAEF,GAAI,EAAM,IAAM,EAAO,GACrB,MAAO,CACL,OAAQ,EACR,SAAU,0CAA0C,EAAM,KAAK,sFAC/D,QACE,uQAGJ,EAEF,MAAM,IAAI,EACR,kEACuB,EAAM,MAAM,UAAU,EAAM,KAAK,8BACjC,EAAO,MAAM,UAAU,EAAO,KAAK,UAC1D,mLAEF,CACF,CASA,eAAsB,EAAY,EAAoD,CACpF,GAAM,CAAE,YAAa,EACf,EAAa,EAAQ,cAAgB,QACrC,EAAS,EAAgB,CAAU,EACzC,GAAI,IAAW,IAAA,GACb,MAAM,IAAI,EACR,0BAA0B,EAAW,IACrC,uBAAuB,EAAkB,CAAC,CAAC,KAAK,IAAI,GACtD,EAEF,IAAM,EAAa,EAAQ,YAAc,EAGnC,EAAQ,MAFG,EAAQ,UAAY,EAAA,CAER,CAAQ,EAC/B,EAAS,MAAM,EAAW,EAAU,CAAoB,EAC9D,GAAI,EAAO,KAAK,IAAM,GACpB,MAAM,IAAI,EAAkB,GAAG,EAAS,WAAW,EAIrD,IAAM,EAAqB,EAAe,CAAM,EAChD,GAAI,IAAuB,KACzB,MAAM,IAAI,EACR,6CAA6C,EAAS,GACtD,kMAGF,EAEF,GAAM,CAAE,aAAc,EAChB,EAAwB,IAAc,IAAM,MAAQ,MAIpD,EAAkB,EADR,EAAc,EAAQ,CAE9B,EACL,GACC,EACE,EAAM,OAAQ,GAAM,IAAM,EAAE,EAC5B,EAAO,MACT,CAAC,CAAC,IACN,EACM,CAAE,aAAc,EAGhB,EAAO,CACX,WACA,SACA,YACA,YACA,GAAI,EAAQ,UAAY,IAAA,IAAa,CAAE,QAAS,EAAQ,OAAQ,CAClE,EACM,EAAQ,MAAM,EAAW,CAAE,GAAG,EAAM,MAAO,EAAM,CAAC,EAKlD,CAAE,SAAQ,SAAU,EAAe,QAAS,GAAiB,EAAc,EAFxD,EAAM,cAAgB,CAAC,EAAM,GACpB,MAAM,EAAW,CAAE,GAAG,EAAM,MAAO,GAAI,CAAC,EAAI,IACgB,EAExF,EAAqB,CAAC,EACxB,IAAiB,MAAM,EAAS,KAAK,CAAY,EAErD,IAAM,EAAU,EAAO,QACvB,GAAI,EAAQ,SAAW,EACrB,MAAM,IAAI,EACR,GAAG,EAAS,0BACZ,yCACF,EAIF,IAAM,EAAW,EAAe,EAAS,EAAO,MAAM,EAChD,EAAc,IAAI,IAAI,EAAO,OAAO,IAAK,GAAM,CAAC,EAAE,IAAK,CAAC,CAAC,CAAC,EAC1D,EAA6B,CAAC,EAC9B,EAA6B,CAAC,EACpC,IAAK,IAAM,KAAU,EAAS,CAC5B,IAAM,EAAS,EAAS,IAAI,CAAM,EAClC,GAAI,IAAW,IAAA,GAAW,CACxB,IAAM,EAAQ,EAAY,IAAI,CAAM,EACpC,EAAU,KAAK,CACb,SACA,SACA,WAAY,EAAkB,GAAO,MAAQ,QAAQ,CACvD,CAAC,EACD,QACF,CACA,EAAS,KAAK,EAAiB,CAAM,CAAC,CACxC,CAGA,IAAM,EAAgB,EACnB,OAAQ,GAAM,EAAY,IAAI,EAAE,MAAM,CAAC,EAAE,OAAS,QAAQ,CAAC,CAC3D,IAAK,GAAM,EAAE,MAAM,EAChB,EAA0B,CAAC,EACjC,IAAK,IAAM,KAAU,EACnB,IAAK,IAAM,KAAK,EAAO,QAAQ,IAAI,CAAM,GAAK,CAAC,EAAG,EAAc,KAAK,CAAC,EAExE,IAAM,EAAK,EAAkB,CAAa,EACtC,EAAG,SAAW,MAOhB,EAAS,KACP,2CAA2C,EAAG,WAAW,KAAK,EAAG,SAAS,yDAE5E,EAEE,EAAG,SAAS,OAAS,GACvB,EAAS,KACP,GAAG,EAAG,SAAS,OAAO,mFACA,EAAG,SAAS,KAAK,IAAI,EAAE,2CAC/C,EAEE,EAAc,SAAW,GAC3B,EAAS,KACP,+GAEF,EAWF,IAAM,EAAiB,EACnB,EAAiB,CAAE,QAAS,CAAC,GAAG,CAAO,EAAG,KAAM,EAAO,UAAW,CAAC,EACnE,KAEJ,MAAO,CACL,QAAA,EACA,KAAM,CAAE,KAAM,EAAU,OAAM,EAC9B,aAAc,EACd,OAAQ,CAAE,SAAQ,YAAW,YAAW,MAAO,EAAO,KAAM,EAC5D,aAAc,EAAG,OACjB,uBAAwB,EAAG,WAC3B,YACA,WACA,iBACA,SAAU,EAAO,KACjB,UACE,EAAQ,UAAY,IAAA,IAAa,EAAQ,QAAU,GAAK,EAAO,MAAQ,EAAQ,QACjF,SAAU,CACR,UAAW,EAAmB,SAC9B,UAAW,EAAgB,SAC3B,MAAO,EACP,aAAc,EAAG,QACnB,EACA,UACF,CACF,CAMA,SAAS,EAAiB,EAAgC,CAexD,OAdI,EAA4B,IAAI,EAAU,CAAM,CAAC,EAC5C,CACL,SACA,OACE,IAAI,EAAO,8fAOb,WAAY,CAAC,GAAG,CAAiB,CACnC,EAEK,CACL,SACA,OAAQ,4BAA4B,EAAO,IAC3C,WAAY,CAAC,CACf,CACF,CAGA,SAAgB,EAAgB,EAAkC,CAChE,OAAO,EAAS,SAAS,IAAK,GAAM,EAAE,MAAM,CAC9C"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"carmaker-feed-B-QYr3PI.mjs","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"sourcesContent":["/**\n * Carmaker-feed transform — now a THIN WRAPPER over the configurable mapping\n * engine (commerce-demo PR2). The hardcoded 14/15-column parsing logic is RETIRED:\n * this declares the carmaker layout as a built-in `parts` `MappingConfig` and\n * delegates to `buildMappingTransform`. The shared `partsSchema` does code\n * normalization (stripping the per-run `supplierCodePrefix`), the EUR currency\n * gate, price validation, and `PartsDocument` assembly — so the carmaker feed and\n * every operator-built `parts` mapping run through one row taxonomy.\n *\n * **Behaviour**: the emitted `PartsDocument` is equivalent to the old hardcoded\n * transform for in-spec rows, with deliberate differences worth knowing:\n * - It now populates `name_ro` from the 15-column feed's `Description_RO` — the\n * old 14-column transform always left it null (PR1 added the field).\n * - Number/currency cells are more lenient: surrounding whitespace is trimmed,\n * empty / lowercase `na` count as absent, and a blank/unmapped currency\n * defaults to EUR (the old transform rejected a blank `CurrencyCd`).\n * - The error TAXONOMY is uniform target-field naming (`missing_code`/`code`,\n * `unsupported_currency`/`currency`, empty required price → `missing_price`)\n * rather than source-column-specific (`missing_article_name`/`ArticleName`),\n * since the engine is source-column-agnostic.\n *\n * The carmaker doc-shape suite (27 tests) plus the leniency tests below it are the\n * regression net.\n *\n * **Decisions still enforced (now via the shared parts schema):**\n * - **D2** Code normalized (supplier prefix stripped, uppercased, dash/space-stripped).\n * - **D4** ES `_id` = `<code_normalized>__<supplierId>`.\n * - **D9** EUR-only (non-EUR rows rejected).\n * - **D21** Bulk-write path bypasses entity hooks (audit on the `import_run`).\n *\n * **`*S` rows** are core-fee lines (commerce-demo SD002 / D004), handled by the shared\n * parts schema (PR7): a `<code>*S` row is NOT a saleable doc — it skips the index and\n * attaches its price as the PARENT part's `core_charge_minor` in the runner's phase-2\n * pass. So the carmaker feed's `*S` rows now route the core charge instead of passing\n * through as standalone docs. Replacement codes remain the dealer superseding-code\n * column (D003), never `*S`.\n *\n * Numbers use the `europeanNumber` op (comma decimal, dot thousands, bare-dot\n * rejected) to match the carmaker feed's documented format exactly; the\n * config's `numberFormat` is unused by that op but required by the schema.\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { buildMappingTransform, type MappingConfig } from '../mapping/engine.js'\nimport { type PartsParams, partsParamsSchema, partsSchema } from '../mapping/parts-schema.js'\nimport type { RowTransform } from '../transform.js'\n\n/** Stable name under which this transform is registered. */\nexport const CARMAKER_FEED_TRANSFORM_NAME = 'commerce:carmaker-feed'\n\n/**\n * Per-job params — the per-(supplier, brand) identity resolved at enqueue time.\n * This IS the parts schema's params (re-exported), so brand/supplier injection +\n * the optional `supplierCodePrefix` strip validate identically everywhere.\n */\nexport const carmakerFeedParamsSchema = partsParamsSchema\nexport type CarmakerFeedParams = PartsParams\n\n/**\n * The built-in carmaker `parts` mapping (15 columns incl. `Description_RO`). Every\n * column carries a `trim` op; the two price columns use `europeanNumber`. The code\n * column is mapped RAW — the parts schema strips the supplier prefix (from params)\n * and normalizes, so both `doc.code` (raw) and `doc.code_normalized` are produced.\n *\n * Exported (not module-local) so a consumer building a real `import_mapping` row\n * for a GIGA brand — which needs a `stripPrefix` transform baked into the `code`\n * column instead of the legacy path's `params.supplierCodePrefix` — can start from\n * this exact layout rather than re-deriving it (see commerce's `seed.ts`).\n */\nexport const CARMAKER_MAPPING: MappingConfig = {\n targetSchema: 'parts',\n numberFormat: { decimal: ',', thousands: '.' },\n columnMap: [\n { source: 'ArticleName', target: 'code', transforms: [{ op: 'trim' }] },\n { source: 'NetPrice/Discount', target: 'net_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'GrossPrice', target: 'gross_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'CurrencyCd', target: 'currency', transforms: [{ op: 'trim' }] },\n { source: 'Barcode', target: 'barcode', transforms: [{ op: 'trim' }] },\n { source: 'Description1', target: 'description1', transforms: [{ op: 'trim' }] },\n { source: 'Description2', target: 'description2', transforms: [{ op: 'trim' }] },\n { source: 'Description_DE', target: 'name_de', transforms: [{ op: 'trim' }] },\n { source: 'Description_EN', target: 'name_en', transforms: [{ op: 'trim' }] },\n { source: 'Description_ES', target: 'name_es', transforms: [{ op: 'trim' }] },\n { source: 'Description_FR', target: 'name_fr', transforms: [{ op: 'trim' }] },\n { source: 'Description_IT', target: 'name_it', transforms: [{ op: 'trim' }] },\n { source: 'Description_NL', target: 'name_nl', transforms: [{ op: 'trim' }] },\n { source: 'Description_PT', target: 'name_pt', transforms: [{ op: 'trim' }] },\n { source: 'Description_RO', target: 'name_ro', transforms: [{ op: 'trim' }] },\n ],\n}\n\n/**\n * The carmaker transform — built once at module load from the static mapping. The\n * build validates every target against the `parts` whitelist (throws on a typo).\n */\nexport const carmakerFeedTransform: RowTransform<PartsDocument> = buildMappingTransform(\n CARMAKER_MAPPING,\n partsSchema,\n)\n"],"mappings":"iGAgDA,MAAa,EAA+B,yBAO/B,EAA2B,EAc3B,EAAkC,CAC7C,aAAc,QACd,aAAc,CAAE,QAAS,IAAK,UAAW,GAAI,EAC7C,UAAW,CACT,CAAE,OAAQ,cAAe,OAAQ,OAAQ,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACtE,CAAE,OAAQ,oBAAqB,OAAQ,YAAa,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EAC3F,CAAE,OAAQ,aAAc,OAAQ,cAAe,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EACtF,CAAE,OAAQ,aAAc,OAAQ,WAAY,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACzE,CAAE,OAAQ,UAAW,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACrE,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,CAC9E,CACF,EAMa,EAAqD,EAChE,EACA,CACF"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"carmaker-feed-CWrZLYUt.d.mts","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"mappings":";;;;;;;cAgDa,4BAAA;;;;;;cAOA,wBAAA,gBAAwB,SAAA;;;;;;;;;;;;;;;mBAAoB,MAAA;AAAA;;;;;;;mBAAA,MAAA;AAAA;AAAA,KAC7C,kBAAA,GAAqB,WAAW;;;;;;;;;;;;cAa/B,gBAAA,EAAkB,aAoB9B;;;;;cAMY,qBAAA,EAAuB,YAAY,CAAC,aAAA"}
@@ -1 +0,0 @@
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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"}