@murumets-ee/imports 0.83.0 → 0.84.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.
@@ -1,2 +1,2 @@
1
- import{n as e}from"../read-feed-DGjqejw3.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};
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
2
  //# sourceMappingURL=index.mjs.map
package/dist/index.d.mts CHANGED
@@ -230,14 +230,14 @@ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
230
230
  id: import("@murumets-ee/entity").IdField;
231
231
  } & import("@murumets-ee/entity").AuditableFields & Record<never, never> & {
232
232
  /** FK to commerce `supplier` (by name string — no cross-package import). Required. */supplierId: import("@murumets-ee/entity").ReferenceField & {
233
- cardinality: "one" | "many";
233
+ cardinality: "many" | "one";
234
234
  } & {
235
235
  readonly entity: "supplier";
236
236
  readonly required: true;
237
237
  readonly indexed: true;
238
238
  }; /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */
239
239
  brandId: import("@murumets-ee/entity").ReferenceField & {
240
- cardinality: "one" | "many";
240
+ cardinality: "many" | "one";
241
241
  } & {
242
242
  readonly entity: "brand";
243
243
  readonly indexed: true;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{a as e,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./transform-Cu5YEoOl.mjs";import{a as u,n as d,o as f,r as p,t as m}from"./read-feed-DGjqejw3.mjs";import{n as h,t as g}from"./cleanup-DDljBe0Z.mjs";import{n as _,r as v}from"./engine-B0ExE4jL.mjs";import{a as y,c as b,d as x,f as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k,u as A}from"./runner-C9GIe5Dr.mjs";import{n as j}from"./carmaker-feed-B-QYr3PI.mjs";import{DEFAULT_BOUNDED_CONCURRENCY as M,runBounded as N}from"@murumets-ee/core";const P=[`corrupt`,`clean`,`source_unavailable`,`undetectable`,`error`],F=[`succeeded`],I=5e3;function L(e){return e instanceof Error?e.message:String(e)}const R=new Set([`ENOENT`,`EACCES`,`EPERM`,`EISDIR`,`ENOTDIR`]),z=/FILE_ENDED/;function B(e,t){if(typeof e!=`object`||!e)return!1;if(e instanceof t)return!0;if(`code`in e){let{code:t}=e;if(typeof t==`string`&&R.has(t))return!0}return e instanceof Error&&z.test(e.message)}async function V(e){let{filePath:t,feed:n,resolveFilePath:r}=e;if(n.format!==`xlsx`)return{verdict:`undetectable`,detail:`not an xlsx run (format: ${n.format??`tsv (default)`}) — the defect is exceljs-specific`};if(n.hasHeader===!1)return{verdict:`undetectable`,detail:`run used hasHeader:false — no header row for the guard to test`};let{streamXlsx:i,XlsxEmptyHeaderError:a,XlsxNotAFileError:o}=await import(`./xlsx-reader-_ioPAqm5.mjs`),s;try{s=r?await r(t):{localPath:t}}catch(e){return{verdict:`source_unavailable`,detail:L(e)}}try{let e=!1;for await(let t of i({filePath:s.localPath,...n.headerRowIndex!==void 0&&{headerRowIndex:n.headerRowIndex},...n.sheet!==void 0&&{sheet:n.sheet}})){e=!0;break}return e?{verdict:`clean`}:{verdict:`undetectable`,detail:`file yielded no data rows — the header guard never ran`}}catch(e){return e instanceof a?{verdict:`corrupt`,detail:e.message}:B(e,o)?{verdict:`source_unavailable`,detail:L(e)}:{verdict:`error`,detail:L(e)}}finally{await s.cleanup?.().catch(()=>{})}}function H(e,t){let n=e[t];return typeof n==`number`&&Number.isFinite(n)?n:void 0}function U(e){if(typeof e!=`object`||!e||Array.isArray(e))return{};let t=e;return{rowsRead:H(t,`rowsRead`),rowsSucceeded:H(t,`rowsSucceeded`),rowsFailed:H(t,`rowsFailed`),rowsSkipped:H(t,`rowsSkipped`)}}function W(){return{corrupt:0,clean:0,source_unavailable:0,undetectable:0,error:0}}function G(e){return e.rowsRead===void 0||e.rowsSucceeded===void 0?`unknown`:e.rowsRead>0&&e.rowsSucceeded===0}function K(e){if(e instanceof Date)return e;if(typeof e==`string`){let t=new Date(e);return Number.isNaN(t.getTime())?null:t}return null}async function q(e){let{app:t,resolveFilePath:n,logger:r}=e,i=e.statuses??F;if(i.length===0)throw Error(`auditEmptyHeaderRuns: statuses must name at least one import_run status`);let a=e.limit??200;if(!Number.isInteger(a)||a<1||a>I)throw Error(`auditEmptyHeaderRuns: limit must be an integer in [1, ${I}] (got ${a})`);let o=e.concurrency??M;if(!Number.isInteger(o)||o<1||o>16)throw Error(`auditEmptyHeaderRuns: concurrency must be an integer in [1, 16] (got ${o})`);let s=t.getClient(l),{and:c,desc:u,getTableColumns:d,inArray:f,sql:m}=await import(`drizzle-orm`),h=d(s.getTable()),g=h.status,_=h.params,v=h.createdAt;if(!g||!_||!v)throw Error(`auditEmptyHeaderRuns: import_run schema missing expected columns (status/params/createdAt)`);let y=c(f(g,[...i]),m`${_}->'feed'->>'format' = ${`xlsx`}`),b=await s.count({where:y}),x=await s.findMany({where:y,orderBy:u(v),limit:a}),S=Math.max(0,b-x.length);if(S>0&&r.warn({candidates:b,examined:x.length,notExamined:S,limit:a},`imports audit: capped at the most recent runs — re-run with a higher limit to cover the rest`),x.length===0)return r.info({statuses:i},`imports audit: no xlsx runs to audit`),{candidates:b,examined:0,notExamined:S,counts:W(),runs:[]};r.info({candidates:b,examining:x.length,concurrency:o},`imports audit: re-reading source files for the empty-header defect`);let C=await N(x,async e=>{let t=e.filePath,r=U(e.totals),i={id:e.id,label:e.label,filePath:t,transformName:e.transformName,createdAt:K(e.createdAt),finishedAt:K(e.finishedAt),totals:r,zeroRowsIndexed:G(r)};try{let r=p(e.params);if(!r.success){let e=r.error.issues[0]?.message??`invalid feed reader config`;return{...i,verdict:`error`,detail:`invalid params.feed: ${e}`}}let a=await V({filePath:t,feed:r.data,...n!==void 0&&{resolveFilePath:n}});return{...i,verdict:a.verdict,...a.detail!==void 0&&{detail:a.detail}}}catch(e){return{...i,verdict:`error`,detail:L(e)}}},o),w=W();for(let e of C)w[e.verdict]+=1,e.verdict===`corrupt`&&r.warn({importRunId:e.id,label:e.label,filePath:e.filePath,transformName:e.transformName,createdAt:e.createdAt,totals:e.totals},`imports audit: SILENTLY CORRUPTED run — its source file has no readable header`);let T=C.filter(e=>e.zeroRowsIndexed===!0).length,E=C.filter(e=>e.zeroRowsIndexed===`unknown`).length;return n===void 0&&w.source_unavailable===C.length&&r.warn({examined:C.length},`imports audit: every source was unreadable and no resolveFilePath was given — if filePath holds storage keys rather than local paths, pass storageResolveFilePath`),r.info({candidates:b,examined:C.length,notExamined:S,...w,zeroRowsIndexed:T,totalsUnknown:E},`imports audit: complete`),{candidates:b,examined:C.length,notExamined:S,counts:w,runs:C}}const J=100,Y=20;async function X(e){let{maxRows:t,...n}=e,r=t??20,i=Math.min(100,Math.max(1,Number.isFinite(r)?Math.floor(r):20)),a=[],o=new Map;for await(let{row:e}of d({...n,relaxColumnCount:!0})){for(let t of Object.keys(e))o.set(t,!0);if(a.push(e),a.length>=i)break}return{columns:[...o.keys()],rows:a}}export{j as CARMAKER_MAPPING,k as DEFAULT_BATCH_SIZE,w as DEFAULT_MAX_PATTERNS,A as DEFAULT_MAX_SAMPLES_PER_PATTERN,Y as DEFAULT_PREVIEW_ROWS,P as EMPTY_HEADER_VERDICTS,D as EMPTY_PRICE_STATS,x as ErrorTracker,n as IMPORT_MAPPING_FORMATS,e as IMPORT_MAPPING_TARGET_SCHEMAS,r as IMPORT_RUN_STATUSES,a as ImportMapping,l as ImportRun,J as MAX_PREVIEW_ROWS,C as PRICE_ANOMALY_THRESHOLD_MULTIPLIER,y as PRICE_SAMPLE_CAP,E as PriceStatsCollector,c as TransformRegistry,s as assertValidMapping,q as auditEmptyHeaderRuns,g as cleanupOrphanedImports,h as cleanupProcessedImports,u as countDataRows,O as detectPriceAnomaly,m as feedReaderConfigSchema,i as getTransformRegistry,t as importMappingValidation,V as inspectImportRunSource,_ as mappingColumnSchema,v as mappingConfigSchema,S as mergeErrorTrackerSnapshots,b as parsePriceStats,X as previewFeed,d as readFeed,p as readPersistedFeedConfig,U as readRecordedTotals,o as registerImportTransform,T as runImport,f as streamFeed};
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
2
  //# sourceMappingURL=index.mjs.map
@@ -1,2 +1,2 @@
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-_ioPAqm5.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-DGjqejw3.mjs.map
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
@@ -1 +1 @@
1
- {"version":3,"file":"read-feed-DGjqejw3.mjs","names":[],"sources":["../src/streaming.ts","../src/read-feed.ts"],"sourcesContent":["/**\n * Tab-delimited / CSV streaming reader. Generic over delimiter so the\n * same path handles `.txt` (tab), `.csv` (comma), and the rare `;`\n * European export dialect.\n *\n * Built on `csv-parse` per giga-test precedent — node-stream-based, low\n * memory, handles UTF-8 BOM (the carmaker feeds are Windows-exported\n * and ship with a BOM that breaks naive split-on-tab parsers).\n *\n * The reader yields `{ rowNumber, row }` pairs where:\n * - `rowNumber` is 1-based and counts the header as row 0.\n * - `row` is `Record<string, string>` keyed by header name. Empty cells\n * are the empty string, NOT `undefined` — feed transforms test with\n * `value === ''` consistently.\n *\n * Why this lives in a streaming reader and not inside the transform\n * itself: the transform sees one already-parsed row at a time, never\n * the file. That keeps transform implementations free of I/O concerns\n * and makes them trivially unit-testable with a fixture row map.\n */\n\nimport { createReadStream } from 'node:fs'\nimport { parse } from 'csv-parse'\n\n/**\n * Throw if two (non-empty) header names collide. Headers are the keys the mapping\n * engine matches a `source` against; after trimming, two columns differing only in\n * whitespace would collapse to one key and silently overwrite each other's data.\n * Fail fast instead — the operator fixes the feed or the mapping. Shared by the\n * csv/tsv reader (below) and the xlsx reader. Blank columns are ignored (unmapped).\n */\nexport function assertUniqueHeaders(header: readonly string[]): void {\n const seen = new Set<string>()\n for (const h of header) {\n if (h === '') continue\n if (seen.has(h)) {\n throw new Error(`Duplicate header \"${h}\" after trimming — column names must be unique`)\n }\n seen.add(h)\n }\n}\n\n/**\n * Pre-pass: count data rows in a delimited file (excluding the header\n * row when `hasHeader` is true; default `true` to match `streamFeed`).\n *\n * Implementation: stream the file as raw bytes and tally `\\n`. NOT a\n * full parse — escaped newlines inside quoted fields would over-count.\n * That was unconditionally fine while `quote: false` was the only\n * behaviour available; a mapping can now declare RFC 4180 (`quote`),\n * and an embedded newline in a quoted field then makes this an UPPER\n * BOUND rather than a count. It is used only as the progress\n * denominator (`totalRows`), so the visible consequence is a bar that\n * stops short of 100%, never a wrong import. The parsed row count from\n * `streamFeed` is authoritative and is what the run records.\n *\n * Why bytes-only and not csv-parse: a 16 MB feed counts in ~50 ms with\n * `\\n` tally vs ~500 ms with csv-parse parsing. For 500 MB the gap\n * matters — pre-pass needs to be a small fraction of the actual import\n * or the operator perceives the run as \"stuck on pre-pass\" with no UI.\n *\n * Returns the number of DATA rows (header excluded when present).\n * Empty trailing lines (no `\\n` after the final row, or a single\n * trailing `\\n`) are handled identically to how csv-parse treats them\n * with `skip_empty_lines: true`.\n */\nexport async function countDataRows(\n filePath: string,\n options: { hasHeader?: boolean } = {},\n): Promise<number> {\n const { hasHeader = true } = options\n const stream = createReadStream(filePath)\n let count = 0\n let lastByte = 0\n for await (const chunk of stream as AsyncIterable<Buffer>) {\n for (let i = 0; i < chunk.length; i += 1) {\n if (chunk[i] === 0x0a /* \\n */) count += 1\n }\n if (chunk.length > 0) lastByte = chunk[chunk.length - 1] ?? 0\n }\n // If the file doesn't end with a newline, the final row didn't get\n // counted by the `\\n` tally — add one. Skipping when the file is\n // entirely empty (count=0 and lastByte=0).\n if (lastByte !== 0 && lastByte !== 0x0a) count += 1\n return hasHeader ? Math.max(0, count - 1) : count\n}\n\nexport interface StreamFeedOptions {\n /** Path to the file on disk. The PoC uploads land on local disk; S3-keyed reads come later. */\n filePath: string\n /**\n * Single-character field delimiter. Default `\\t` (the carmaker feed\n * format). Pass `,` for CSV, `;` for some European dialects.\n */\n delimiter?: string\n /**\n * `true` (default): the first row is the header and column names come\n * from it. `false`: rows are emitted as positional `{ \"0\": ..., \"1\": ... }`\n * and the transform reads by index — useful for headerless feeds that\n * commit to a documented column order.\n */\n hasHeader?: boolean\n /**\n * Optional explicit column-name list. When provided, takes precedence\n * over `hasHeader` (header row, if present, is skipped but its values\n * are ignored). Useful when the upstream header is unstable but the\n * positional shape isn't.\n */\n columns?: ReadonlyArray<string>\n /**\n * Forward to `csv-parse` `relax_column_count`. Default `false` —\n * a row whose column count doesn't match the header surfaces as a\n * parser error so the transform isn't silently fed truncated data.\n */\n relaxColumnCount?: boolean\n /**\n * Forward to `csv-parse` `quote`. The character (or `false`) that\n * encloses fields containing the delimiter. Default `false` — most\n * supplier feeds in this package's target use case are tab-delimited\n * exports that contain literal `\"` characters in description fields\n * (e.g. `LIVRE \"INVERSION D'IMAGE\"` from the Mercedes carmaker feed)\n * with NO quote-as-field-wrapper convention. csv-parse's library\n * default `'\"'` then misreads the literal `\"` as an opening quote\n * and rejects the row with `Invalid Opening Quote`.\n *\n * Pass `'\"'` (or any character) to opt back into RFC-4180-style\n * CSV parsing for genuine quoted-CSV inputs. Pass `'\\''` for\n * single-quoted dialects.\n */\n quote?: string | false\n}\n\nexport interface StreamFeedRow {\n /** 1-based row number. Header (when present) is row 0; first data row is row 1. */\n rowNumber: number\n /**\n * Cell values keyed by column name (or string-position when\n * `hasHeader: false` AND no `columns`).\n *\n * **Cell-value invariants:**\n * - Empty cells (`A\\t\\tC`) → `''` (empty string).\n * - Missing TRAILING cells in `relaxColumnCount: true` mode → the\n * key is **absent** from the object, not present-with-`''`. csv-parse\n * does not emit keys for short rows. Transforms reading those\n * columns get `undefined` from `row['col']` and must handle it\n * (`row['col'] ?? ''` is the canonical idiom).\n * - With the default `relaxColumnCount: false`, short rows reject at\n * the parser, so this case never reaches the transform.\n */\n row: Record<string, string>\n}\n\n/**\n * Async-iterable over the parsed rows of a delimited file. Use with\n * `for await (const { rowNumber, row } of streamFeed({ filePath, ... }))`.\n *\n * The iterator owns its file descriptor — the `for await` loop closes\n * the underlying stream when it returns or breaks. Aborting mid-stream\n * (`break`, `throw`, signal) is safe; csv-parse propagates the close.\n */\nexport async function* streamFeed(options: StreamFeedOptions): AsyncIterable<StreamFeedRow> {\n const {\n filePath,\n delimiter = '\\t',\n hasHeader = true,\n columns,\n relaxColumnCount = false,\n quote = false,\n } = options\n\n // When the caller provides explicit `columns`, prefer them. When the file\n // has a header but no explicit `columns`, derive the column names from the\n // first row via a function so we can TRIM them — real supplier feeds ship\n // leading/trailing-space headers (R03 Samples A/C: `\" JAEHIND KM-TA\"`), and\n // an untrimmed key wouldn't match a mapping's `source`. The xlsx reader trims\n // headers the same way. When neither is true, rows get string-position keys.\n //\n // csv-parse's `columns` accepts `ColumnOption[] | boolean | ((record) =>\n // ColumnOption[])`; `string[]` satisfies `ColumnOption[]`.\n const columnConfig: string[] | boolean | ((header: string[]) => string[]) = columns\n ? Array.from(columns)\n : hasHeader\n ? (header: string[]) => {\n const trimmed = header.map((h) => h.trim())\n assertUniqueHeaders(trimmed)\n return trimmed\n }\n : false\n\n const stream = createReadStream(filePath)\n const parser = stream.pipe(\n parse({\n delimiter,\n columns: columnConfig,\n bom: true,\n skip_empty_lines: true,\n // csv-parse defaults to strict column count; opt-in relaxation only.\n relax_column_count: relaxColumnCount,\n // Default `quote: false` — see StreamFeedOptions.quote JSDoc.\n // Carmaker / parts-feed exports contain literal `\"` characters\n // in description fields and would otherwise reject as\n // `Invalid Opening Quote`. Genuine RFC-4180 CSV consumers opt\n // back in by passing `quote: '\"'`.\n quote,\n }),\n )\n\n let rowNumber = 0\n try {\n for await (const rawRow of parser as AsyncIterable<\n ReadonlyArray<string> | Record<string, string | undefined>\n >) {\n rowNumber += 1\n // csv-parse emits records with string keys when `columns` is\n // truthy, otherwise an Array. Normalise both shapes to\n // `Record<string, string>` so transforms can rely on `value === ''`\n // for missing cells (csv-parse leaves trailing missing cells as\n // `undefined` when `relax_column_count: true`; this collapses\n // them to `''` to keep the contract uniform).\n const row: Record<string, string> = {}\n if (Array.isArray(rawRow)) {\n const arr = rawRow as ReadonlyArray<string | undefined>\n for (let i = 0; i < arr.length; i += 1) {\n row[String(i)] = arr[i] ?? ''\n }\n } else {\n for (const [k, v] of Object.entries(rawRow)) {\n row[k] = v ?? ''\n }\n }\n yield { rowNumber, row }\n }\n } finally {\n // Safety: ensure the underlying file descriptor closes even if the\n // consumer breaks mid-iteration. Node closes streams on\n // garbage-collection but this makes it deterministic under tests.\n stream.destroy()\n }\n}\n","/**\n * Feed reader dispatcher (commerce-demo phase 02, D005). Selects the reader by\n * `format` and yields the uniform `AsyncIterable<{ rowNumber, row }>` the runner\n * consumes — csv/tsv via the existing `streamFeed` (csv-parse), xlsx via the\n * exceljs-backed `streamXlsx`.\n *\n * **Back-compat**: with no `format` (the carmaker worker passes only `filePath`),\n * this defaults to tab-delimited — the original `streamFeed` behaviour — so the\n * existing import path is unchanged.\n *\n * **Bundle safety**: the xlsx reader (and its heavy, Node-only exceljs dep) is\n * `await import()`-ed ONLY for `format: 'xlsx'`, so the csv/tsv path — and anything\n * that statically imports the runner — never pulls exceljs into its module graph.\n */\n\nimport { z } from 'zod'\nimport { type StreamFeedOptions, type StreamFeedRow, streamFeed } from './streaming.js'\n\n/**\n * The reader-config subset safe to persist in `import_run.params.feed` and rehydrate\n * in the worker (commerce-demo PR3). It is exactly the {@link ReadFeedOptions} fields\n * that describe HOW to parse a feed — `filePath` is supplied by the worker after it\n * materialises the upload, never carried in params. Validated at BOTH ends: the\n * commerce run route builds it from a saved `import_mapping`, the worker re-parses it\n * before handing it to {@link readFeed} (defense-in-depth against a tampered row).\n */\nexport const feedReaderConfigSchema = z\n .object({\n format: z.enum(['csv', 'tsv', 'xlsx']).optional(),\n delimiter: z.string().min(1).max(4).optional(),\n hasHeader: z.boolean().optional(),\n headerRowIndex: z.number().int().min(0).optional(),\n sheet: z.string().min(1).max(128).optional(),\n /**\n * csv-parse's `quote`. Absent (or `false`) = quotes are ordinary data, which is this\n * package's long-standing default and what the tab-delimited supplier dumps require. A\n * single character opts into RFC 4180 for feeds that genuinely quote their fields.\n *\n * Persisted deliberately: the two shapes are mutually exclusive on real data (R027 —\n * each of the three ground-truth feeds hard-fails under the other's setting), so this\n * is a per-file fact a mapping has to carry. Deriving it from `format` would be the\n * same class of mistake as a global decimal separator.\n */\n quote: z.union([z.string().length(1), z.literal(false)]).optional(),\n })\n .strict()\nexport type FeedReaderConfig = z.infer<typeof feedReaderConfigSchema>\n\n/**\n * Rehydrate the reader config a run persisted in `import_run.params.feed`.\n *\n * ONE implementation, because two would drift and drift here is silent: the\n * worker uses it to decide how to READ a feed, and the #842 audit uses it to\n * re-read the same file the same way. An audit that rehydrates differently from\n * the run it is auditing invents corruption, or misses it.\n *\n * ABSENT means \"the carmaker default\", i.e. tab-delimited, which is what an\n * empty config parses to — so a non-object `params`, or `params` with no\n * `feed`, is a real answer rather than a gap. A PRESENT `feed` is validated\n * whatever its shape: a scalar or an array there is a malformed persisted blob,\n * and reading it as \"no config\" would silently import an xlsx run as tsv.\n */\nexport function readPersistedFeedConfig(\n params: unknown,\n): ReturnType<typeof feedReaderConfigSchema.safeParse> {\n const record =\n params && typeof params === 'object' && !Array.isArray(params)\n ? (params as Record<string, unknown>)\n : {}\n // `null` is how \"absent\" round-trips through JSONB, so it defaults with\n // `undefined`. Everything else present goes to the schema and is rejected on\n // its merits rather than being flattened into the default.\n const feed = record.feed === undefined || record.feed === null ? {} : record.feed\n return feedReaderConfigSchema.safeParse(feed)\n}\n\nexport interface ReadFeedOptions {\n /** Path to the file on local disk. */\n filePath: string\n /** Feed format. Omitted → tab-delimited (tsv), matching the legacy carmaker reader. */\n format?: 'csv' | 'tsv' | 'xlsx'\n /** Explicit delimiter override (csv/tsv). Defaults from `format` (`csv` → `,`, else `\\t`). */\n delimiter?: string\n /** First row is the header. Default `true`. */\n hasHeader?: boolean\n /** 0-based header row index (xlsx). */\n headerRowIndex?: number\n /** xlsx worksheet as a 1-based index string. Omit → first sheet. */\n sheet?: string\n /** Explicit column-name list (csv/tsv) — see `streamFeed`. */\n columns?: ReadonlyArray<string>\n /** Forward to csv-parse `relax_column_count` (csv/tsv). */\n relaxColumnCount?: boolean\n /** Forward to csv-parse `quote` (csv/tsv). */\n quote?: string | false\n}\n\nfunction toStreamFeedOptions(o: ReadFeedOptions): StreamFeedOptions {\n // No format → tab (the carmaker feed default). `csv` → comma; everything else → tab.\n const delimiter = o.delimiter ?? (o.format === 'csv' ? ',' : '\\t')\n return {\n filePath: o.filePath,\n delimiter,\n ...(o.hasHeader !== undefined && { hasHeader: o.hasHeader }),\n ...(o.columns !== undefined && { columns: o.columns }),\n ...(o.relaxColumnCount !== undefined && { relaxColumnCount: o.relaxColumnCount }),\n ...(o.quote !== undefined && { quote: o.quote }),\n }\n}\n\n/** Stream a feed file's data rows, dispatching on `format`. */\nexport async function* readFeed(options: ReadFeedOptions): AsyncIterable<StreamFeedRow> {\n if (options.format === 'xlsx') {\n const { streamXlsx } = await import('./xlsx-reader.js')\n yield* streamXlsx({\n filePath: options.filePath,\n ...(options.hasHeader !== undefined && { hasHeader: options.hasHeader }),\n ...(options.headerRowIndex !== undefined && { headerRowIndex: options.headerRowIndex }),\n ...(options.sheet !== undefined && { sheet: options.sheet }),\n })\n return\n }\n yield* streamFeed(toStreamFeedOptions(options))\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"read-feed-DsLgRDNR.mjs","names":[],"sources":["../src/streaming.ts","../src/read-feed.ts"],"sourcesContent":["/**\n * Tab-delimited / CSV streaming reader. Generic over delimiter so the\n * same path handles `.txt` (tab), `.csv` (comma), and the rare `;`\n * European export dialect.\n *\n * Built on `csv-parse` per giga-test precedent — node-stream-based, low\n * memory, handles UTF-8 BOM (the carmaker feeds are Windows-exported\n * and ship with a BOM that breaks naive split-on-tab parsers).\n *\n * The reader yields `{ rowNumber, row }` pairs where:\n * - `rowNumber` is 1-based and counts the header as row 0.\n * - `row` is `Record<string, string>` keyed by header name. Empty cells\n * are the empty string, NOT `undefined` — feed transforms test with\n * `value === ''` consistently.\n *\n * Why this lives in a streaming reader and not inside the transform\n * itself: the transform sees one already-parsed row at a time, never\n * the file. That keeps transform implementations free of I/O concerns\n * and makes them trivially unit-testable with a fixture row map.\n */\n\nimport { createReadStream } from 'node:fs'\nimport { parse } from 'csv-parse'\n\n/**\n * Throw if two (non-empty) header names collide. Headers are the keys the mapping\n * engine matches a `source` against; after trimming, two columns differing only in\n * whitespace would collapse to one key and silently overwrite each other's data.\n * Fail fast instead — the operator fixes the feed or the mapping. Shared by the\n * csv/tsv reader (below) and the xlsx reader. Blank columns are ignored (unmapped).\n */\nexport function assertUniqueHeaders(header: readonly string[]): void {\n const seen = new Set<string>()\n for (const h of header) {\n if (h === '') continue\n if (seen.has(h)) {\n throw new Error(`Duplicate header \"${h}\" after trimming — column names must be unique`)\n }\n seen.add(h)\n }\n}\n\n/**\n * Pre-pass: count data rows in a delimited file (excluding the header\n * row when `hasHeader` is true; default `true` to match `streamFeed`).\n *\n * Implementation: stream the file as raw bytes and tally `\\n`. NOT a\n * full parse — escaped newlines inside quoted fields would over-count.\n * That was unconditionally fine while `quote: false` was the only\n * behaviour available; a mapping can now declare RFC 4180 (`quote`),\n * and an embedded newline in a quoted field then makes this an UPPER\n * BOUND rather than a count. It is used only as the progress\n * denominator (`totalRows`), so the visible consequence is a bar that\n * stops short of 100%, never a wrong import. The parsed row count from\n * `streamFeed` is authoritative and is what the run records.\n *\n * Why bytes-only and not csv-parse: a 16 MB feed counts in ~50 ms with\n * `\\n` tally vs ~500 ms with csv-parse parsing. For 500 MB the gap\n * matters — pre-pass needs to be a small fraction of the actual import\n * or the operator perceives the run as \"stuck on pre-pass\" with no UI.\n *\n * Returns the number of DATA rows (header excluded when present).\n * Empty trailing lines (no `\\n` after the final row, or a single\n * trailing `\\n`) are handled identically to how csv-parse treats them\n * with `skip_empty_lines: true`.\n */\nexport async function countDataRows(\n filePath: string,\n options: { hasHeader?: boolean } = {},\n): Promise<number> {\n const { hasHeader = true } = options\n const stream = createReadStream(filePath)\n let count = 0\n let lastByte = 0\n for await (const chunk of stream as AsyncIterable<Buffer>) {\n for (let i = 0; i < chunk.length; i += 1) {\n if (chunk[i] === 0x0a /* \\n */) count += 1\n }\n if (chunk.length > 0) lastByte = chunk[chunk.length - 1] ?? 0\n }\n // If the file doesn't end with a newline, the final row didn't get\n // counted by the `\\n` tally — add one. Skipping when the file is\n // entirely empty (count=0 and lastByte=0).\n if (lastByte !== 0 && lastByte !== 0x0a) count += 1\n return hasHeader ? Math.max(0, count - 1) : count\n}\n\nexport interface StreamFeedOptions {\n /** Path to the file on disk. The PoC uploads land on local disk; S3-keyed reads come later. */\n filePath: string\n /**\n * Single-character field delimiter. Default `\\t` (the carmaker feed\n * format). Pass `,` for CSV, `;` for some European dialects.\n */\n delimiter?: string\n /**\n * `true` (default): the first row is the header and column names come\n * from it. `false`: rows are emitted as positional `{ \"0\": ..., \"1\": ... }`\n * and the transform reads by index — useful for headerless feeds that\n * commit to a documented column order.\n */\n hasHeader?: boolean\n /**\n * Optional explicit column-name list. When provided, takes precedence\n * over `hasHeader` (header row, if present, is skipped but its values\n * are ignored). Useful when the upstream header is unstable but the\n * positional shape isn't.\n */\n columns?: ReadonlyArray<string>\n /**\n * Forward to `csv-parse` `relax_column_count`. Default `false` —\n * a row whose column count doesn't match the header surfaces as a\n * parser error so the transform isn't silently fed truncated data.\n */\n relaxColumnCount?: boolean\n /**\n * Forward to `csv-parse` `quote`. The character (or `false`) that\n * encloses fields containing the delimiter. Default `false` — most\n * supplier feeds in this package's target use case are tab-delimited\n * exports that contain literal `\"` characters in description fields\n * (e.g. `LIVRE \"INVERSION D'IMAGE\"` from the Mercedes carmaker feed)\n * with NO quote-as-field-wrapper convention. csv-parse's library\n * default `'\"'` then misreads the literal `\"` as an opening quote\n * and rejects the row with `Invalid Opening Quote`.\n *\n * Pass `'\"'` (or any character) to opt back into RFC-4180-style\n * CSV parsing for genuine quoted-CSV inputs. Pass `'\\''` for\n * single-quoted dialects.\n */\n quote?: string | false\n}\n\nexport interface StreamFeedRow {\n /** 1-based row number. Header (when present) is row 0; first data row is row 1. */\n rowNumber: number\n /**\n * Cell values keyed by column name (or string-position when\n * `hasHeader: false` AND no `columns`).\n *\n * **Cell-value invariants:**\n * - Empty cells (`A\\t\\tC`) → `''` (empty string).\n * - Missing TRAILING cells in `relaxColumnCount: true` mode → the\n * key is **absent** from the object, not present-with-`''`. csv-parse\n * does not emit keys for short rows. Transforms reading those\n * columns get `undefined` from `row['col']` and must handle it\n * (`row['col'] ?? ''` is the canonical idiom).\n * - With the default `relaxColumnCount: false`, short rows reject at\n * the parser, so this case never reaches the transform.\n */\n row: Record<string, string>\n}\n\n/**\n * Async-iterable over the parsed rows of a delimited file. Use with\n * `for await (const { rowNumber, row } of streamFeed({ filePath, ... }))`.\n *\n * The iterator owns its file descriptor — the `for await` loop closes\n * the underlying stream when it returns or breaks. Aborting mid-stream\n * (`break`, `throw`, signal) is safe; csv-parse propagates the close.\n */\nexport async function* streamFeed(options: StreamFeedOptions): AsyncIterable<StreamFeedRow> {\n const {\n filePath,\n delimiter = '\\t',\n hasHeader = true,\n columns,\n relaxColumnCount = false,\n quote = false,\n } = options\n\n // When the caller provides explicit `columns`, prefer them. When the file\n // has a header but no explicit `columns`, derive the column names from the\n // first row via a function so we can TRIM them — real supplier feeds ship\n // leading/trailing-space headers (R03 Samples A/C: `\" JAEHIND KM-TA\"`), and\n // an untrimmed key wouldn't match a mapping's `source`. The xlsx reader trims\n // headers the same way. When neither is true, rows get string-position keys.\n //\n // csv-parse's `columns` accepts `ColumnOption[] | boolean | ((record) =>\n // ColumnOption[])`; `string[]` satisfies `ColumnOption[]`.\n const columnConfig: string[] | boolean | ((header: string[]) => string[]) = columns\n ? Array.from(columns)\n : hasHeader\n ? (header: string[]) => {\n const trimmed = header.map((h) => h.trim())\n assertUniqueHeaders(trimmed)\n return trimmed\n }\n : false\n\n const stream = createReadStream(filePath)\n const parser = stream.pipe(\n parse({\n delimiter,\n columns: columnConfig,\n bom: true,\n skip_empty_lines: true,\n // csv-parse defaults to strict column count; opt-in relaxation only.\n relax_column_count: relaxColumnCount,\n // Default `quote: false` — see StreamFeedOptions.quote JSDoc.\n // Carmaker / parts-feed exports contain literal `\"` characters\n // in description fields and would otherwise reject as\n // `Invalid Opening Quote`. Genuine RFC-4180 CSV consumers opt\n // back in by passing `quote: '\"'`.\n quote,\n }),\n )\n\n let rowNumber = 0\n try {\n for await (const rawRow of parser as AsyncIterable<\n ReadonlyArray<string> | Record<string, string | undefined>\n >) {\n rowNumber += 1\n // csv-parse emits records with string keys when `columns` is\n // truthy, otherwise an Array. Normalise both shapes to\n // `Record<string, string>` so transforms can rely on `value === ''`\n // for missing cells (csv-parse leaves trailing missing cells as\n // `undefined` when `relax_column_count: true`; this collapses\n // them to `''` to keep the contract uniform).\n const row: Record<string, string> = {}\n if (Array.isArray(rawRow)) {\n const arr = rawRow as ReadonlyArray<string | undefined>\n for (let i = 0; i < arr.length; i += 1) {\n row[String(i)] = arr[i] ?? ''\n }\n } else {\n for (const [k, v] of Object.entries(rawRow)) {\n row[k] = v ?? ''\n }\n }\n yield { rowNumber, row }\n }\n } finally {\n // Safety: ensure the underlying file descriptor closes even if the\n // consumer breaks mid-iteration. Node closes streams on\n // garbage-collection but this makes it deterministic under tests.\n stream.destroy()\n }\n}\n","/**\n * Feed reader dispatcher (commerce-demo phase 02, D005). Selects the reader by\n * `format` and yields the uniform `AsyncIterable<{ rowNumber, row }>` the runner\n * consumes — csv/tsv via the existing `streamFeed` (csv-parse), xlsx via the\n * exceljs-backed `streamXlsx`.\n *\n * **Back-compat**: with no `format` (the carmaker worker passes only `filePath`),\n * this defaults to tab-delimited — the original `streamFeed` behaviour — so the\n * existing import path is unchanged.\n *\n * **Bundle safety**: the xlsx reader (and its heavy, Node-only exceljs dep) is\n * `await import()`-ed ONLY for `format: 'xlsx'`, so the csv/tsv path — and anything\n * that statically imports the runner — never pulls exceljs into its module graph.\n */\n\nimport { z } from 'zod'\nimport { type StreamFeedOptions, type StreamFeedRow, streamFeed } from './streaming.js'\n\n/**\n * The reader-config subset safe to persist in `import_run.params.feed` and rehydrate\n * in the worker (commerce-demo PR3). It is exactly the {@link ReadFeedOptions} fields\n * that describe HOW to parse a feed — `filePath` is supplied by the worker after it\n * materialises the upload, never carried in params. Validated at BOTH ends: the\n * commerce run route builds it from a saved `import_mapping`, the worker re-parses it\n * before handing it to {@link readFeed} (defense-in-depth against a tampered row).\n */\nexport const feedReaderConfigSchema = z\n .object({\n format: z.enum(['csv', 'tsv', 'xlsx']).optional(),\n delimiter: z.string().min(1).max(4).optional(),\n hasHeader: z.boolean().optional(),\n headerRowIndex: z.number().int().min(0).optional(),\n sheet: z.string().min(1).max(128).optional(),\n /**\n * csv-parse's `quote`. Absent (or `false`) = quotes are ordinary data, which is this\n * package's long-standing default and what the tab-delimited supplier dumps require. A\n * single character opts into RFC 4180 for feeds that genuinely quote their fields.\n *\n * Persisted deliberately: the two shapes are mutually exclusive on real data (R027 —\n * each of the three ground-truth feeds hard-fails under the other's setting), so this\n * is a per-file fact a mapping has to carry. Deriving it from `format` would be the\n * same class of mistake as a global decimal separator.\n */\n quote: z.union([z.string().length(1), z.literal(false)]).optional(),\n })\n .strict()\nexport type FeedReaderConfig = z.infer<typeof feedReaderConfigSchema>\n\n/**\n * Rehydrate the reader config a run persisted in `import_run.params.feed`.\n *\n * ONE implementation, because two would drift and drift here is silent: the\n * worker uses it to decide how to READ a feed, and the #842 audit uses it to\n * re-read the same file the same way. An audit that rehydrates differently from\n * the run it is auditing invents corruption, or misses it.\n *\n * ABSENT means \"the carmaker default\", i.e. tab-delimited, which is what an\n * empty config parses to — so a non-object `params`, or `params` with no\n * `feed`, is a real answer rather than a gap. A PRESENT `feed` is validated\n * whatever its shape: a scalar or an array there is a malformed persisted blob,\n * and reading it as \"no config\" would silently import an xlsx run as tsv.\n */\nexport function readPersistedFeedConfig(\n params: unknown,\n): ReturnType<typeof feedReaderConfigSchema.safeParse> {\n const record =\n params && typeof params === 'object' && !Array.isArray(params)\n ? (params as Record<string, unknown>)\n : {}\n // `null` is how \"absent\" round-trips through JSONB, so it defaults with\n // `undefined`. Everything else present goes to the schema and is rejected on\n // its merits rather than being flattened into the default.\n const feed = record.feed === undefined || record.feed === null ? {} : record.feed\n return feedReaderConfigSchema.safeParse(feed)\n}\n\nexport interface ReadFeedOptions {\n /** Path to the file on local disk. */\n filePath: string\n /** Feed format. Omitted → tab-delimited (tsv), matching the legacy carmaker reader. */\n format?: 'csv' | 'tsv' | 'xlsx'\n /** Explicit delimiter override (csv/tsv). Defaults from `format` (`csv` → `,`, else `\\t`). */\n delimiter?: string\n /** First row is the header. Default `true`. */\n hasHeader?: boolean\n /** 0-based header row index (xlsx). */\n headerRowIndex?: number\n /** xlsx worksheet as a 1-based index string. Omit → first sheet. */\n sheet?: string\n /** Explicit column-name list (csv/tsv) — see `streamFeed`. */\n columns?: ReadonlyArray<string>\n /** Forward to csv-parse `relax_column_count` (csv/tsv). */\n relaxColumnCount?: boolean\n /** Forward to csv-parse `quote` (csv/tsv). */\n quote?: string | false\n}\n\nfunction toStreamFeedOptions(o: ReadFeedOptions): StreamFeedOptions {\n // No format → tab (the carmaker feed default). `csv` → comma; everything else → tab.\n const delimiter = o.delimiter ?? (o.format === 'csv' ? ',' : '\\t')\n return {\n filePath: o.filePath,\n delimiter,\n ...(o.hasHeader !== undefined && { hasHeader: o.hasHeader }),\n ...(o.columns !== undefined && { columns: o.columns }),\n ...(o.relaxColumnCount !== undefined && { relaxColumnCount: o.relaxColumnCount }),\n ...(o.quote !== undefined && { quote: o.quote }),\n }\n}\n\n/** Stream a feed file's data rows, dispatching on `format`. */\nexport async function* readFeed(options: ReadFeedOptions): AsyncIterable<StreamFeedRow> {\n if (options.format === 'xlsx') {\n const { streamXlsx } = await import('./xlsx-reader.js')\n yield* streamXlsx({\n filePath: options.filePath,\n ...(options.hasHeader !== undefined && { hasHeader: options.hasHeader }),\n ...(options.headerRowIndex !== undefined && { headerRowIndex: options.headerRowIndex }),\n ...(options.sheet !== undefined && { sheet: options.sheet }),\n })\n return\n }\n yield* streamFeed(toStreamFeedOptions(options))\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{n as e}from"./read-feed-DGjqejw3.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-C9GIe5Dr.mjs.map
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 +1 @@
1
- {"version":3,"file":"runner-C9GIe5Dr.mjs","names":[],"sources":["../src/error-tracker.ts","../src/price-stats.ts","../src/runner.ts"],"sourcesContent":["/**\n * Aggregates per-row errors into top-N pattern buckets so a feed of 1M\n * malformed rows surfaces as a handful of actionable signatures rather\n * than a million identical strings.\n *\n * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and\n * generalized:\n * - Configurable caps so the importer can tune memory bounds per run.\n * - Pure data — no logging side effects, no I/O.\n * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the\n * `import_run.errorSummary` column.\n *\n * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.\n * Same `errorType + field + message` collapses to one bucket; differing\n * messages stay separate. This is intentional: a parser error on column\n * `NetPrice/Discount` (\"invalid number 'NA,5'\") and the same on column\n * `GrossPrice` are operationally distinct even if the parser is the same.\n *\n * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,\n * additional NEW signatures are dropped — known patterns keep accumulating\n * counts. This is the \"top-N most common\" model: rare-but-novel errors\n * past the cap are invisible, but the cap protects against a runaway\n * adversarial feed exploding the map. Sample arrays are independently\n * capped at `maxSamplesPerPattern`.\n */\n\n/**\n * Recursively-defined JSON-serialisable value. Mirrors the shape of\n * `JsonValue` in `@murumets-ee/entity` without taking a dependency on\n * that package — error-tracker is otherwise standalone, and the\n * `ImportRun.errorSummary` JSONB column accepts anything in this shape.\n */\nexport type ImportJsonValue =\n | string\n | number\n | boolean\n | null\n | ImportJsonValue[]\n | { [key: string]: ImportJsonValue }\n\n/** One sample row attached to a pattern. `rowData` is the originally-parsed row. */\nexport interface ErrorSample {\n rowNumber: number\n rowData: ImportJsonValue\n}\n\n/** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */\nexport interface ErrorPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ReadonlyArray<ErrorSample>\n /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */\n percentage: number\n}\n\nexport interface ErrorTrackerConfig {\n /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */\n maxPatterns?: number\n /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */\n maxSamplesPerPattern?: number\n}\n\n/** Default caps — match giga-test for compatibility. */\nexport const DEFAULT_MAX_PATTERNS = 50\nexport const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5\n\ninterface InternalPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ErrorSample[]\n}\n\nexport class ErrorTracker {\n private readonly patterns = new Map<string, InternalPattern>()\n private readonly maxPatterns: number\n private readonly maxSamplesPerPattern: number\n private droppedSignatures = 0\n\n constructor(config: ErrorTrackerConfig = {}) {\n this.maxPatterns = config.maxPatterns ?? DEFAULT_MAX_PATTERNS\n this.maxSamplesPerPattern = config.maxSamplesPerPattern ?? DEFAULT_MAX_SAMPLES_PER_PATTERN\n }\n\n /**\n * Record one error. Same `(errorType, field, message)` triple bumps the\n * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).\n *\n * `field` is optional — pass `undefined` for errors not tied to a single\n * column (e.g. parse errors at row level). Internally normalised to the\n * literal string `'GENERAL'` so it shares a bucket with other genericised\n * errors of the same type+message.\n */\n addError(\n rowNumber: number,\n errorType: string,\n message: string,\n field: string | undefined,\n rowData: ImportJsonValue,\n ): void {\n const fieldKey = field ?? 'GENERAL'\n const signature = `${errorType}:${fieldKey}:${message}`\n\n let pattern = this.patterns.get(signature)\n if (!pattern) {\n if (this.patterns.size >= this.maxPatterns) {\n this.droppedSignatures += 1\n return\n }\n pattern = {\n errorType,\n field: field ?? null,\n message,\n count: 0,\n firstOccurrence: rowNumber,\n lastOccurrence: rowNumber,\n samples: [],\n }\n this.patterns.set(signature, pattern)\n }\n\n pattern.count += 1\n pattern.lastOccurrence = rowNumber\n if (pattern.samples.length < this.maxSamplesPerPattern) {\n pattern.samples.push({ rowNumber, rowData })\n }\n }\n\n /** Total count across every pattern. Counts errors, not patterns. */\n getTotalErrorCount(): number {\n let sum = 0\n for (const p of this.patterns.values()) sum += p.count\n return sum\n }\n\n /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */\n getDistinctPatternCount(): number {\n return this.patterns.size\n }\n\n /**\n * Number of NEW signatures dropped because the map was already at\n * capacity. Surfacing this in the import_run summary tells the operator\n * \"the top-N was saturated — there's a long tail you're not seeing\".\n */\n getDroppedSignatureCount(): number {\n return this.droppedSignatures\n }\n\n /**\n * Top patterns sorted by descending count, capped at `maxPatterns`.\n * Stable secondary order is insertion order (Map iteration order is\n * insertion order; Array.sort is stable in V8).\n */\n getTopPatterns(): ErrorPattern[] {\n const all = Array.from(this.patterns.values()).sort((a, b) => b.count - a.count)\n const totalErrors = all.reduce((sum, p) => sum + p.count, 0)\n return all.map((p) => ({\n errorType: p.errorType,\n field: p.field,\n message: p.message,\n count: p.count,\n firstOccurrence: p.firstOccurrence,\n lastOccurrence: p.lastOccurrence,\n samples: p.samples.slice(),\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n }))\n }\n\n /**\n * Compact JSON-serialisable snapshot for `import_run.errorSummary`.\n * Aside from the patterns array, includes the totals so a reader of\n * just this column doesn't have to re-derive them.\n */\n snapshot(): ErrorTrackerSnapshot {\n return {\n totalErrors: this.getTotalErrorCount(),\n distinctPatterns: this.getDistinctPatternCount(),\n droppedSignatures: this.droppedSignatures,\n patterns: this.getTopPatterns(),\n }\n }\n}\n\nexport interface ErrorTrackerSnapshot {\n totalErrors: number\n distinctPatterns: number\n droppedSignatures: number\n patterns: ErrorPattern[]\n}\n\n/**\n * Merge two independently-produced `ErrorTrackerSnapshot`s into one — e.g.\n * the runner's per-row `warningSummary` plus a worker-level signal computed\n * AFTER the run completes (F010 / #602: the price-drift anomaly check runs\n * in `worker.ts`, after `runImport` returns, using a fresh single-pattern\n * tracker of its own). Patterns concatenate rather than re-dedup: the two\n * snapshots are assumed to use disjoint `errorType` signature spaces (a\n * worker-level tracker uses a type — `price_anomaly` — the runner's own\n * warning aggregation never emits), so counts sum directly with no risk of\n * double-counting one bucket as two.\n */\nexport function mergeErrorTrackerSnapshots(\n a: ErrorTrackerSnapshot,\n b: ErrorTrackerSnapshot,\n): ErrorTrackerSnapshot {\n const totalErrors = a.totalErrors + b.totalErrors\n // Each source snapshot computed `percentage` against its OWN `totalErrors`\n // (see `getTopPatterns` above); the merged total is different, so every\n // pattern's `percentage` must be recomputed against it — a straight\n // concat would leave every value stale (e.g. `20/100=20%` staying `20`\n // once the true merged denominator is `101`). `0` when the merged total\n // is `0`, matching `getTopPatterns`' own zero-total convention.\n const rescale = (p: ErrorPattern): ErrorPattern => ({\n ...p,\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n })\n return {\n totalErrors,\n distinctPatterns: a.distinctPatterns + b.distinctPatterns,\n droppedSignatures: a.droppedSignatures + b.droppedSignatures,\n patterns: [...a.patterns.map(rescale), ...b.patterns.map(rescale)],\n }\n}\n","/**\n * Price-distribution stats + prior-run anomaly detection (F010 / issue #602).\n *\n * The import pipeline's existing anomaly detection — register-handler.ts's\n * \"6b. Header-presence drift check\" — only catches STRUCTURAL drift: a\n * mapped column vanishing (or getting renamed) from the header row. It does\n * NOT catch VALUE drift: a supplier silently flipping their decimal\n * separator (e.g. `1.234,56` → `1234.56`) or thousands separator WITHOUT\n * touching a single column header. Since the saved\n * `import_mapping.numberFormat` (`{decimal, thousands}`) is unchanged,\n * every price in that run parses as a NUMERICALLY VALID but WRONG value —\n * silently corrupting the catalog's prices. Nothing upstream of this module\n * flags it.\n *\n * This module computes a per-run price-distribution snapshot (`PriceStats`)\n * over `PartsDocument.purchase_price_minor` — the DERIVED cost field,\n * rather than the raw net/list price columns, because both the direct-net\n * AND two-file derivation paths in `mapping/parts-schema.ts` (step 4) funnel\n * through it, so a decimal-format flip on EITHER source column surfaces\n * here regardless of which pricing path a supplier's feed uses.\n *\n * Collection (`PriceStatsCollector`) happens in the SAME streaming pass\n * `runner.ts` already uses to build `totals` — one `.record()` call per\n * ES-accepted row, no second pass over the file, no extra DB round-trip.\n * The prior-run comparison (`detectPriceAnomaly`) happens in `worker.ts`\n * AFTER the run completes, because it needs `AdminClient` to look the\n * prior run up — this module stays DB-agnostic, pure data + pure functions.\n */\n\n/** A run's price-distribution snapshot. Values are in MINOR units (cents), matching `PartsDocument.purchase_price_minor`'s own scale. */\nexport interface PriceStats {\n /** ES-accepted rows considered — same accounting as `RunImportResult.rowsSucceeded`. */\n count: number\n /** Fraction (0..1) of `count` whose price sample was absent (e.g. a list-only row with no resolvable purchase cost — the same rows that carry a `no_purchase_price` warning). `0` when `count` is `0`. */\n nullRate: number\n /** Minimum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n min: number | null\n /** Median of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n median: number | null\n /** Maximum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n max: number | null\n}\n\n/** The all-zero/empty snapshot — a run that produced zero ES-accepted rows. */\nexport const EMPTY_PRICE_STATS: PriceStats = {\n count: 0,\n nullRate: 0,\n min: null,\n median: null,\n max: null,\n}\n\n/**\n * Bounded sample cap for the min/median/max computation — the same\n * \"bounded, uniform sample\" philosophy `ErrorTracker` uses top-N patterns\n * for. 50,000 floats is ~400KB, trivial next to the ES bulk-write buffers\n * already in flight for a run this size, and far more than needed for a\n * stable median estimate. A feed with more ES-accepted rows than this cap\n * still gets an EXACT `count` / `nullRate` (those are running counters, not\n * sample-bounded) — only min/median/max become an approximation over a\n * UNIFORM RANDOM sample of `PRICE_SAMPLE_CAP` non-null values, not \"the\n * first N encountered\" (see `record`'s reservoir-sampling note for why\n * order-independence matters specifically for this module's job).\n */\nexport const PRICE_SAMPLE_CAP = 50_000\n\n/**\n * Accumulates price samples during a run's streaming pass. `record` is O(1)\n * (amortized array push below the cap, one RNG draw above it); `snapshot`\n * sorts the retained samples once at the end — cheap relative to the ES\n * bulk-write the run already performs every batch.\n */\nexport class PriceStatsCollector {\n private count = 0\n private nullCount = 0\n private nonNullSeen = 0\n private readonly samples: number[] = []\n\n /**\n * Record one ES-accepted row's price sample. Call exactly once per\n * accepted row (mirrors the `rowsWarned` / `pairs` accounting in\n * `runner.ts`'s flush loop) — `undefined` means \"this row had no price to\n * sample\" and counts toward `nullRate` rather than being skipped.\n *\n * Reservoir sampling (Algorithm R) past the cap, not \"first N wins\": each\n * new non-null value replaces a uniformly-random existing slot with\n * probability `PRICE_SAMPLE_CAP / nonNullSeen`, giving every non-null\n * sample seen so far an equal chance of surviving into the final\n * reservoir. Order-independence matters here specifically — the anomaly\n * this module exists to catch (a supplier's decimal-format flip) could\n * start partway through a feed larger than the cap (e.g. a corrupted\n * re-export appended to an otherwise-normal catalogue), and a first-N\n * sample would never see a single row past the cutover point, silently\n * missing exactly the drift F010 was built to detect.\n */\n record(value: number | undefined): void {\n this.count += 1\n if (value === undefined) {\n this.nullCount += 1\n return\n }\n this.nonNullSeen += 1\n if (this.samples.length < PRICE_SAMPLE_CAP) {\n this.samples.push(value)\n return\n }\n const j = Math.floor(Math.random() * this.nonNullSeen)\n if (j < PRICE_SAMPLE_CAP) this.samples[j] = value\n }\n\n snapshot(): PriceStats {\n if (this.count === 0) return { ...EMPTY_PRICE_STATS }\n const nullRate = this.nullCount / this.count\n if (this.samples.length === 0) {\n return { count: this.count, nullRate, min: null, median: null, max: null }\n }\n const sorted = [...this.samples].sort((a, b) => a - b)\n const min = sorted[0] ?? null\n const max = sorted[sorted.length - 1] ?? null\n const mid = Math.floor(sorted.length / 2)\n let median: number | null\n if (sorted.length % 2 === 0) {\n const lo = sorted[mid - 1]\n const hi = sorted[mid]\n median = lo !== undefined && hi !== undefined ? (lo + hi) / 2 : null\n } else {\n median = sorted[mid] ?? null\n }\n return { count: this.count, nullRate, min, median, max }\n }\n}\n\n/**\n * Anomaly threshold: a run whose median purchase price is >= 10x higher OR\n * <= 1/10 (0.1x) of the prior successful run's median (for the same\n * transform + supplier + brand) is flagged.\n *\n * Chosen generously above the range a real repricing plausibly covers\n * (even an aggressive markup/discount renegotiation is a low-single-digit\n * multiplier — 2x-3x would already be an extreme outlier for a legitimate\n * price change) but comfortably BELOW the ~100x-1000x magnitude a\n * decimal/thousands-separator flip produces for typical EUR part prices:\n * `\"1.234,56\"` (a European-format €1234.56) misparsed with dot-decimal\n * conventions reads as `1.234,56` → `1234.56`, a ~1000x inflation if the\n * true value was e.g. €1.23; a thousands-separator flip on `\"1234,56\"`\n * (true €1234.56) misparsed as `123456` (dropping the decimal entirely) is\n * a ~100x inflation. 10x leaves a wide safety margin against false\n * positives on genuine price changes while still comfortably catching\n * every realistic decimal/thousands-separator flip.\n */\nexport const PRICE_ANOMALY_THRESHOLD_MULTIPLIER = 10\n\nexport interface PriceAnomaly {\n /** `curr.median / prev.median`. */\n ratio: number\n /** The prior run's median (minor units). */\n prevMedian: number\n /** This run's median (minor units). */\n currMedian: number\n}\n\n/**\n * Compare two runs' price stats for a magnitude-scale anomaly. Returns\n * `null` when there's nothing to compare (either side has no median — zero\n * non-null price samples, e.g. a schema/transform that never emits a price\n * sample, or a run with zero ES-accepted rows) or the ratio falls within\n * the safe band.\n */\nexport function detectPriceAnomaly(prev: PriceStats, curr: PriceStats): PriceAnomaly | null {\n if (prev.median === null || curr.median === null) return null\n // A zero/negative median can't produce a meaningful ratio. Shouldn't occur\n // for real minor-unit prices (`toMinorUnits` on a positive price); this is\n // defensive-only so a pathological value can't produce Infinity/NaN below.\n if (prev.median <= 0 || curr.median <= 0) return null\n const ratio = curr.median / prev.median\n if (\n ratio >= PRICE_ANOMALY_THRESHOLD_MULTIPLIER ||\n ratio <= 1 / PRICE_ANOMALY_THRESHOLD_MULTIPLIER\n ) {\n return { ratio, prevMedian: prev.median, currMedian: curr.median }\n }\n return null\n}\n\n/**\n * Narrow an `unknown` value (a `field.json()` column read back through\n * `AdminClient`, typed `JsonValue | undefined` on the DTO) to `PriceStats`,\n * or `null` when it doesn't structurally match — e.g. a run written before\n * this field existed, or a run whose transform never emitted a price\n * sample so `min`/`median`/`max` serialised as `null`. Defensive, matching\n * the `params` narrowing pattern already used in `worker.ts`.\n */\nexport function parsePriceStats(value: unknown): PriceStats | null {\n if (typeof value !== 'object' || value === null) return null\n const v = value as Record<string, unknown>\n if (typeof v.count !== 'number' || typeof v.nullRate !== 'number') return null\n return {\n count: v.count,\n nullRate: v.nullRate,\n min: typeof v.min === 'number' ? v.min : null,\n median: typeof v.median === 'number' ? v.median : null,\n max: typeof v.max === 'number' ? v.max : null,\n }\n}\n","/**\n * One-shot importer: stream rows → transform → batched bulk-write to ES,\n * accumulating per-row errors into `ErrorTracker` and reporting progress\n * to the queue every batch.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope):\n * - **Batch size 1000.** Matches giga-test precedent. Configurable for\n * integration tests that don't want a 1k row floor.\n * - **No resumability and no automatic retries.** The `imports:run` queue\n * job is registered with `defaultRetries: 0` (see `worker.ts`) so a\n * failed handler does NOT re-enqueue itself — re-running a multi-batch\n * import against the same `import_run.id` while the previous attempt\n * may still be writing is a footgun (duplicate batches, double-counted\n * progress). Operator retries by creating a NEW `import_run` row.\n * - **No per-supplier transform plugin.** The runner takes a single\n * `RowTransform<TDoc>` from the registry and applies it to every row;\n * PR 8 may diverge but only by registering a different transform name.\n * - **Direct `bulkUpsert` into the live aliased index** (D6 alias is\n * set up by PR 4's `ensureAliasedIndex`; the importer doesn't reindex).\n *\n * Per D21 (sanctioned bulk path): this runner intentionally bypasses\n * AdminClient and entity hooks. Per-batch audit / observability lives\n * on the surrounding `import_run` row + queue progress, NOT per-row.\n */\n\nimport {\n type BulkIndexResult,\n bulkPartialUpdate,\n bulkUpsert,\n type EsClientLike,\n} from '@murumets-ee/search-elasticsearch'\nimport { ErrorTracker } from './error-tracker.js'\nimport { type PriceStats, PriceStatsCollector } from './price-stats.js'\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\nimport type {\n DirectionalPair,\n DocPatch,\n RowTransform,\n RowWarning,\n TransformContext,\n} from './transform.js'\n\n/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */\nexport const DEFAULT_BATCH_SIZE = 1000\n\nexport interface RunImportOptions<TDoc> {\n /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */\n importRunId: string\n /** Operator-supplied label for the run. Forwarded to the transform context. */\n runLabel: string\n /** Opaque per-run params copied from `import_run.params`. */\n params: Record<string, unknown>\n /** Transform applied to every parsed row. */\n transform: RowTransform<TDoc>\n /** Feed reader options — file path + format (csv/tsv/xlsx) + delimiter/header config. */\n feed: ReadFeedOptions\n /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */\n esClient: EsClientLike\n /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */\n esIndex: string\n /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */\n batchSize?: number\n /**\n * Callback invoked after every batch. The handler in `worker.ts`\n * forwards this to `ctx.updateProgress` for the queue UI; tests\n * inspect it directly. Synchronous + cheap so a slow callback can't\n * back-pressure the importer.\n */\n onProgress?: (progress: ImportRunProgress) => void\n /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */\n rowLimit?: number\n /** Abort signal threaded into the underlying ES client request — cooperative cancel. */\n signal?: AbortSignal\n /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */\n errorTracker?: ErrorTracker\n /**\n * Optional separate tracker for NON-FATAL row warnings (commerce-demo PR4 —\n * `no_purchase_price`). Distinct from {@link errorTracker} so warnings never\n * inflate `totals.failed` / the error summary: a warned row is a SUCCESS whose\n * doc is indexed. Default: a fresh tracker with the same caps.\n */\n warningTracker?: ErrorTracker\n /**\n * Optional price-stats collector (F010 / #602) — accumulates a\n * min/median/max/nullRate distribution over ES-accepted rows' optional\n * `priceSample`s in the SAME streaming pass that builds `totals`. Default:\n * a fresh collector. Tests can inject their own to inspect it directly —\n * mirrors the {@link errorTracker} / {@link warningTracker} DI shape.\n */\n priceStats?: PriceStatsCollector\n /**\n * Optional structural logger for per-batch telemetry. Receives\n * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after\n * every flush, plus a one-shot `started` and `finished` event. The\n * worker forwards `app.logger.child({ pkg: 'imports' })`; tests\n * leave it undefined.\n *\n * Why: without per-batch visibility, a stalled run looks identical\n * to \"first batch in progress\" forever — the only feedback is the\n * queue's `progress` jsonb (which writes after each batch) and the\n * eventual `succeeded`/`failed` job event. Adding this lets the\n * operator tail the worker log and immediately see whether ES is\n * rate-limiting, the transform is rejecting every row, or the\n * stream is actually progressing.\n */\n logger?: {\n info: (data: Record<string, unknown>, msg: string) => void\n warn: (data: Record<string, unknown>, msg: string) => void\n }\n}\n\n/**\n * Progress payload written to `toolkit_jobs.progress` after every batch.\n * Caps + flush rules live on the queue's `updateProgress` debounce —\n * callers don't need to throttle.\n */\nexport interface ImportRunProgress {\n rowsRead: number\n rowsSucceeded: number\n rowsFailed: number\n rowsSkipped: number\n /** Rows indexed WITH a non-fatal warning (commerce-demo PR4 — `no_purchase_price`). */\n rowsWarned: number\n batchesCompleted: number\n /** Wall-clock seconds since the runner started. */\n elapsedSeconds: number\n /** Rows / second, computed at every batch. */\n rowsPerSecond: number\n /** Distinct error patterns currently held by the tracker. Saturates at the cap. */\n distinctErrorPatterns: number\n /**\n * Total data-row count from a pre-pass scan, when the worker computed one.\n * Lets the UI render `processed / total` percent. Absent when the worker\n * skipped the count (file too large for the count budget, count failed,\n * etc.) — UI must treat it as optional.\n */\n totalRows?: number\n}\n\n/**\n * Final result returned by {@link runImport}. The handler writes these\n * onto the `import_run` row alongside the ErrorTracker snapshot.\n */\nexport interface RunImportResult {\n /** Total rows read from the file (excludes skipped empty lines). */\n rowsRead: number\n /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */\n rowsSucceeded: number\n /**\n * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES\n * rejected on bulk-write (per-doc failure). Both are aggregated by\n * `errorTracker` for the import_run summary.\n */\n rowsFailed: number\n /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */\n rowsSkipped: number\n /**\n * Rows that succeeded (doc indexed) but carried ≥1 non-fatal warning —\n * counted SEPARATELY from `rowsSucceeded`/`rowsFailed` (a warned row is also\n * counted in `rowsSucceeded`). commerce-demo PR4: `no_purchase_price`.\n */\n rowsWarned: number\n /** Number of `bulkUpsert` calls made. */\n batchesCompleted: number\n /** Final value of {@link ErrorTracker.snapshot}. */\n errors: ReturnType<ErrorTracker['snapshot']>\n /** Aggregated NON-FATAL warnings (commerce-demo PR4). Empty when no row warned. */\n warnings: ReturnType<ErrorTracker['snapshot']>\n /**\n * Directional supersession pairs harvested as a side-output (commerce-demo PR6,\n * SD007). Buffered in memory from ES-ACCEPTED rows only (same accounting as\n * warnings — a row whose doc was rejected by ES contributes no pair). Empty unless\n * a schema emits them (the `parts` schema, when a row's `superseding_code` is set).\n * The worker hands these to an injected `pairSink` AFTER the run for the commerce\n * replacement-merge engine; the runner itself never writes the consumer's tables.\n */\n pairs: readonly DirectionalPair[]\n /**\n * Doc-patches applied by the phase-2 pass (commerce-demo PR7): a `<code>*S`\n * core-fee row is not a saleable doc — it emits a {@link DocPatch} that merges its\n * deposit onto the PARENT part's `core_charge_minor`. The runner buffers these\n * during the stream (sparse) and applies them via `bulkPartialUpdate` AFTER the ES\n * pass. `patchesApplied` = targets the cluster merged; `patchesOrphaned` = patches\n * whose parent genuinely wasn't in the index (a `document_missing`/404 → an\n * `orphan_core_charge` warning). An INFRA failure (whole-batch transport error, or a\n * non-404 per-doc failure) is NOT counted as orphaned — it lands in a distinct\n * `patch_attach_failed` warning bucket so it can't masquerade as a data orphan. Zero\n * unless a schema emits patches.\n */\n patchesApplied: number\n patchesOrphaned: number\n /**\n * Price-distribution snapshot (F010 / #602) accumulated from ES-accepted\n * rows' `priceSample`s during this same streaming pass. See `price-stats.ts`.\n * `count` is `0` (all fields `null`/`0`) for a run whose schema never emits\n * a price sample.\n */\n priceStats: PriceStats\n}\n\n/**\n * Apply the runner against a feed file. Stops on rowLimit OR end-of-file\n * OR if `signal` aborts. Throws if the streaming reader / ES client\n * throws — caller (the queue handler) catches that and writes\n * `import_run.status = 'failed'` with the error message in\n * `errorSummary.fatal`.\n */\nexport async function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult> {\n const {\n importRunId,\n runLabel,\n params,\n transform,\n feed,\n esClient,\n esIndex,\n batchSize = DEFAULT_BATCH_SIZE,\n onProgress,\n rowLimit,\n signal,\n errorTracker = new ErrorTracker(),\n warningTracker = new ErrorTracker(),\n priceStats: priceStatsCollector = new PriceStatsCollector(),\n logger,\n } = options\n\n if (batchSize < 1) {\n throw new Error(`batchSize must be >= 1 (got ${batchSize})`)\n }\n\n const startedAt = Date.now()\n logger?.info({ importRunId, runLabel, batchSize, esIndex }, 'imports.runner: started')\n let rowsRead = 0\n let rowsSucceeded = 0\n let rowsFailed = 0\n let rowsSkipped = 0\n let rowsWarned = 0\n let batchesCompleted = 0\n\n // Side-output buffer (commerce-demo PR6, SD007): directional supersession pairs\n // accumulated from ES-ACCEPTED rows across the whole run. An array append per\n // sparse superseded row — it never blocks the `bulkUpsert` loop. Handed to the\n // injected `pairSink` by the worker AFTER the run (read-once / merge-after).\n const collectedPairs: DirectionalPair[] = []\n\n // Side-output buffer (commerce-demo PR7): doc-patches from `*S` core-fee rows —\n // each attaches a core charge onto a DIFFERENT (parent) doc. Collected off the\n // `skip` result (a `*S` row is never a saleable doc), sparse, applied in a phase-2\n // `bulkPartialUpdate` after the ES pass (below) — order-independent because ES\n // `update` is realtime by `_id`.\n const collectedPatches: DocPatch<TDoc>[] = []\n\n // Warnings + pairs ride on the pending item (with the raw row for the warning\n // sample) so they're accounted at FLUSH time — only for rows ES actually accepted.\n // A warned row is by contract a SUCCESSFUL row, so counting it at push time (before\n // the bulk response) would wrongly keep a later-ES-rejected row in rowsWarned /\n // warnings; pairs follow the same accepted-rows-only rule.\n let pending: Array<{\n id: string\n doc: TDoc\n rowNumber: number\n rowData: Record<string, string>\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n priceSample?: number\n }> = []\n\n const flush = async (): Promise<void> => {\n if (pending.length === 0) return\n const batch = pending\n pending = []\n const flushStartedAt = Date.now()\n let result: BulkIndexResult\n try {\n result = await bulkUpsert<TDoc>(esClient, {\n index: esIndex,\n docs: batch.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // Cluster- or transport-level failure — the whole batch is\n // unaccounted for. Distinguish abort (operator-driven cancel) from\n // a real cluster failure so the errorSummary doesn't mislabel a\n // cancelled run as broken cluster connectivity.\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n const errorType = isAbort ? 'aborted' : 'bulk_request_failed'\n const reason = err instanceof Error ? err.message : String(err)\n for (const { rowNumber } of batch) {\n errorTracker.addError(rowNumber, errorType, reason, undefined, null)\n }\n rowsFailed += batch.length\n throw err\n }\n\n rowsSucceeded += result.succeeded\n rowsFailed += result.failures.length\n batchesCompleted += 1\n\n const flushMs = Date.now() - flushStartedAt\n const elapsedSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n batch: batchesCompleted,\n flushMs,\n submitted: batch.length,\n succeeded: result.succeeded,\n failed: result.failures.length,\n rowsRead,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n },\n 'imports.runner: batch flushed',\n )\n\n if (result.failures.length > 0) {\n // Map each ES failure back to its source row via `id`. The bulk\n // response order matches the request order, but ES doesn't promise\n // that; matching by `id` is the safe path. PoC volume is small\n // enough that the O(failures × batch) cost is irrelevant.\n const byId = new Map(batch.map((b) => [b.id, b.rowNumber]))\n for (const fail of result.failures) {\n const rowNumber = byId.get(fail.id) ?? -1\n errorTracker.addError(rowNumber, fail.type, fail.reason, undefined, { id: fail.id })\n }\n }\n\n // Account non-fatal warnings ONLY for rows ES accepted (commerce-demo PR4) — a\n // warned row is by contract a SUCCESSFUL row, so a row that warned AND then failed\n // the bulk write must NOT land in rowsWarned/warnings (it's already in rowsFailed).\n const failedIds = new Set(result.failures.map((f) => f.id))\n for (const item of batch) {\n if (!item.warnings || item.warnings.length === 0) continue\n if (failedIds.has(item.id)) continue\n rowsWarned += 1\n for (const w of item.warnings) {\n warningTracker.addError(item.rowNumber, w.warningType, w.message, w.field, item.rowData)\n }\n }\n\n // Buffer directional pairs for rows ES accepted (commerce-demo PR6, SD007) — same\n // accepted-rows-only rule as warnings, so a pair from a doc ES rejected is dropped.\n for (const item of batch) {\n if (!item.pairs || item.pairs.length === 0) continue\n if (failedIds.has(item.id)) continue\n collectedPairs.push(...item.pairs)\n }\n\n // Price-distribution sampling (F010 / #602) — same accepted-rows-only rule as\n // warnings/pairs: a row ES rejected contributes no sample. Every ES-accepted row\n // records exactly once (`undefined` counts toward `nullRate` rather than being\n // skipped), so `priceStats.count` tracks `rowsSucceeded` exactly.\n for (const item of batch) {\n if (failedIds.has(item.id)) continue\n priceStatsCollector.record(item.priceSample)\n }\n\n if (onProgress) {\n const elapsedSeconds = (Date.now() - startedAt) / 1000\n onProgress({\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n elapsedSeconds,\n rowsPerSecond: elapsedSeconds > 0 ? rowsRead / elapsedSeconds : 0,\n distinctErrorPatterns: errorTracker.getDistinctPatternCount(),\n })\n }\n }\n\n // Streaming heartbeat — sample every Nth row so the operator sees the\n // streamer is alive even when a transform-rejected file would prevent\n // any flush from firing. Without this, a feed that's 100% rejected\n // looks identical to \"stuck\" until end-of-file. Sample frequency is\n // batchSize so the cadence matches the natural batch rhythm.\n const streamSampleEvery = batchSize\n\n // First-error capture per error type — surface what's actually rejecting\n // the rows when 100% of a feed is rejected. Without this, the only signal\n // is `errorTracker.snapshot()` which only writes to import_run.errorSummary\n // at the END of the run. Mid-run we want to know NOW.\n const firstErrorByType = new Map<string, { rowNumber: number; message: string }>()\n\n for await (const { rowNumber, row } of readFeed(feed)) {\n if (signal?.aborted) break\n if (rowLimit !== undefined && rowsRead >= rowLimit) break\n rowsRead += 1\n\n if (rowsRead === 1) {\n logger?.info(\n { importRunId, rowNumber, sampleKeys: Object.keys(row) },\n 'imports.runner: STREAM_FIRST_ROW',\n )\n }\n if (rowsRead % streamSampleEvery === 0) {\n const elapsedSec = (Date.now() - startedAt) / 1000\n // Telemetry only — no row content, no error sample text.\n // PR #262 review (M-5): the prior `firstErrors:\n // Object.fromEntries(firstErrorByType)` payload included the\n // raw error message captured from the transform, which for the\n // carmaker feed embeds supplier codes + price fragments. The\n // detailed first-error breakdown is already persisted on\n // `import_run.errorSummary` via `errorTracker.snapshot()` —\n // operators inspect it there, not in the worker log.\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n pending: pending.length,\n elapsedSec: Math.round(elapsedSec * 10) / 10,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n distinctFirstErrorTypes: firstErrorByType.size,\n },\n 'imports.runner: STREAM_TICK',\n )\n }\n\n const ctx: TransformContext = { importRunId, params, runLabel, rowNumber }\n let result: Awaited<ReturnType<typeof transform>>\n try {\n result = await transform(row, ctx)\n } catch (err) {\n // A throw from the transform is a programmer error — surface it as\n // a row-level error so the run can continue. (If the bug is\n // catastrophic, the operator sees the same message repeated and\n // can stop the run.)\n const reason = err instanceof Error ? err.message : String(err)\n errorTracker.addError(rowNumber, 'transform_threw', reason, undefined, row)\n if (!firstErrorByType.has('transform_threw')) {\n firstErrorByType.set('transform_threw', { rowNumber, message: reason })\n // PR #262 review (M-5): drop the `row` payload from the\n // log line — supplier feeds carry pricing + internal codes.\n // The full row is on `import_run.errorSummary.samples` for\n // post-mortem review by operators with view permission.\n logger?.warn(\n { importRunId, rowNumber, errorType: 'transform_threw', message: reason },\n 'imports.runner: FIRST_ERROR transform_threw',\n )\n }\n rowsFailed += 1\n continue\n }\n\n if (result.kind === 'skip') {\n rowsSkipped += 1\n // A `*S` core-fee row skips the saleable index but may carry a doc-patch\n // (commerce-demo PR7) — buffer it for the phase-2 attach. Unlike pairs, a\n // patch has no ES write of its own to gate on, so it's collected here\n // unconditionally; its success is decided by the phase-2 update.\n if (result.patches && result.patches.length > 0) {\n collectedPatches.push(...result.patches)\n }\n continue\n }\n if (result.kind === 'error') {\n errorTracker.addError(\n rowNumber,\n result.error.errorType,\n result.error.message,\n result.error.field,\n row,\n )\n if (!firstErrorByType.has(result.error.errorType)) {\n firstErrorByType.set(result.error.errorType, {\n rowNumber,\n message: result.error.message,\n })\n logger?.warn(\n {\n importRunId,\n rowNumber,\n errorType: result.error.errorType,\n field: result.error.field,\n message: result.error.message,\n },\n `imports.runner: FIRST_ERROR ${result.error.errorType}`,\n )\n }\n rowsFailed += 1\n continue\n }\n\n // Non-fatal warnings ride on a SUCCESS — the doc is still indexed\n // (commerce-demo PR4: `no_purchase_price`). Carry them (+ the raw row for the\n // sample) on the pending item; the flush aggregates them into the separate\n // `warningTracker` AFTER the bulk response confirms the row was accepted, so a\n // warned-then-ES-rejected row never inflates rowsWarned / the warning summary.\n pending.push({\n id: result.id,\n doc: result.doc,\n rowNumber,\n rowData: row,\n ...(result.warnings && result.warnings.length > 0 && { warnings: result.warnings }),\n ...(result.pairs && result.pairs.length > 0 && { pairs: result.pairs }),\n ...(result.priceSample !== undefined && { priceSample: result.priceSample }),\n })\n if (pending.length >= batchSize) {\n await flush()\n }\n }\n\n await flush()\n\n // Phase 2 (commerce-demo PR7): attach the buffered `*S` core-charge patches onto\n // their PARENT docs. Runs AFTER the whole stream so every parent that exists in\n // this run is already indexed; ES `update` is realtime by `_id`, so the merge\n // lands regardless of whether the parent streamed before or after its `*S` row\n // (order-independent). Patches are sparse (only reman parts carry `*S`), but batch\n // anyway for a giant catalogue. A patch whose parent isn't in the index comes back\n // as a per-doc failure (`document_missing_exception`) → an `orphan_core_charge`\n // warning, never a phantom doc, never a silent drop (D004). A cluster failure of\n // the whole batch is logged as a warning too — the saleable docs already indexed;\n // only the deferred core-charge attach didn't happen — so we never fail the run\n // over it.\n let patchesApplied = 0\n let patchesOrphaned = 0\n for (let i = 0; i < collectedPatches.length; i += batchSize) {\n // Operator-driven cancel BETWEEN batches: THROW rather than break, so a run\n // cancelled after phase 1 is not marked `succeeded` with core charges left\n // unattached. Mirrors phase-1's flush, which also re-throws on abort. The queue\n // handler catches this and marks the run failed.\n if (signal?.aborted) {\n throw new Error('imports: run aborted during core-charge attach (phase 2)')\n }\n const slice = collectedPatches.slice(i, i + batchSize)\n let updateResult: BulkIndexResult\n try {\n updateResult = await bulkPartialUpdate<TDoc>(esClient, {\n index: esIndex,\n updates: slice.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // An abort surfacing as an AbortError from the ES client is a cancel, NOT an\n // attach failure — re-throw so the run fails (same as phase 1), never a silent\n // \"succeeded with patches unapplied\".\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n if (isAbort) throw err\n // Whole-batch transport/cluster failure (ES down, timeout). The parents may\n // well exist — the attach simply didn't happen — so this is an INFRA failure,\n // NOT a data orphan: it gets the distinct `patch_attach_failed` bucket and is\n // NOT counted in patchesOrphaned. Best-effort: the saleable docs already\n // indexed, and a re-import re-attaches, so we warn + continue rather than fail.\n const reason = err instanceof Error ? err.message : String(err)\n for (const patch of slice) {\n warningTracker.addError(-1, 'patch_attach_failed', reason, patch.orphanWarning?.field, {\n target: patch.id,\n })\n }\n continue\n }\n patchesApplied += updateResult.succeeded\n if (updateResult.failures.length > 0) {\n const byId = new Map(slice.map((p) => [p.id, p]))\n for (const fail of updateResult.failures) {\n const w = byId.get(fail.id)?.orphanWarning\n // A missing target (`document_missing_exception` / 404) is a genuine data\n // orphan — the referenced parent isn't in this import (D004: warn, never a\n // phantom doc). A stable message (from the patch's orphanWarning) aggregates\n // orphans; the missing parent id rides in the sample. Any OTHER per-doc\n // failure (mapper error, etc.) is an infra/attach failure kept in a distinct\n // bucket so it can't masquerade as a data orphan.\n const isOrphan = fail.status === 404 || fail.type === 'document_missing_exception'\n if (isOrphan) {\n patchesOrphaned += 1\n warningTracker.addError(\n -1,\n w?.warningType ?? 'orphan_patch',\n w?.message ?? fail.reason,\n w?.field,\n {\n target: fail.id,\n },\n )\n } else {\n warningTracker.addError(-1, 'patch_attach_failed', fail.reason, w?.field, {\n target: fail.id,\n })\n }\n }\n }\n }\n\n const totalSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n patchesApplied,\n patchesOrphaned,\n totalSec: Math.round(totalSec * 10) / 10,\n rowsPerSecond: totalSec > 0 ? Math.round(rowsRead / totalSec) : 0,\n },\n 'imports.runner: finished',\n )\n\n return {\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n errors: errorTracker.snapshot(),\n warnings: warningTracker.snapshot(),\n pairs: collectedPairs,\n patchesApplied,\n patchesOrphaned,\n priceStats: priceStatsCollector.snapshot(),\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"runner-BnFvID1z.mjs","names":[],"sources":["../src/error-tracker.ts","../src/price-stats.ts","../src/runner.ts"],"sourcesContent":["/**\n * Aggregates per-row errors into top-N pattern buckets so a feed of 1M\n * malformed rows surfaces as a handful of actionable signatures rather\n * than a million identical strings.\n *\n * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and\n * generalized:\n * - Configurable caps so the importer can tune memory bounds per run.\n * - Pure data — no logging side effects, no I/O.\n * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the\n * `import_run.errorSummary` column.\n *\n * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.\n * Same `errorType + field + message` collapses to one bucket; differing\n * messages stay separate. This is intentional: a parser error on column\n * `NetPrice/Discount` (\"invalid number 'NA,5'\") and the same on column\n * `GrossPrice` are operationally distinct even if the parser is the same.\n *\n * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,\n * additional NEW signatures are dropped — known patterns keep accumulating\n * counts. This is the \"top-N most common\" model: rare-but-novel errors\n * past the cap are invisible, but the cap protects against a runaway\n * adversarial feed exploding the map. Sample arrays are independently\n * capped at `maxSamplesPerPattern`.\n */\n\n/**\n * Recursively-defined JSON-serialisable value. Mirrors the shape of\n * `JsonValue` in `@murumets-ee/entity` without taking a dependency on\n * that package — error-tracker is otherwise standalone, and the\n * `ImportRun.errorSummary` JSONB column accepts anything in this shape.\n */\nexport type ImportJsonValue =\n | string\n | number\n | boolean\n | null\n | ImportJsonValue[]\n | { [key: string]: ImportJsonValue }\n\n/** One sample row attached to a pattern. `rowData` is the originally-parsed row. */\nexport interface ErrorSample {\n rowNumber: number\n rowData: ImportJsonValue\n}\n\n/** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */\nexport interface ErrorPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ReadonlyArray<ErrorSample>\n /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */\n percentage: number\n}\n\nexport interface ErrorTrackerConfig {\n /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */\n maxPatterns?: number\n /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */\n maxSamplesPerPattern?: number\n}\n\n/** Default caps — match giga-test for compatibility. */\nexport const DEFAULT_MAX_PATTERNS = 50\nexport const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5\n\ninterface InternalPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ErrorSample[]\n}\n\nexport class ErrorTracker {\n private readonly patterns = new Map<string, InternalPattern>()\n private readonly maxPatterns: number\n private readonly maxSamplesPerPattern: number\n private droppedSignatures = 0\n\n constructor(config: ErrorTrackerConfig = {}) {\n this.maxPatterns = config.maxPatterns ?? DEFAULT_MAX_PATTERNS\n this.maxSamplesPerPattern = config.maxSamplesPerPattern ?? DEFAULT_MAX_SAMPLES_PER_PATTERN\n }\n\n /**\n * Record one error. Same `(errorType, field, message)` triple bumps the\n * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).\n *\n * `field` is optional — pass `undefined` for errors not tied to a single\n * column (e.g. parse errors at row level). Internally normalised to the\n * literal string `'GENERAL'` so it shares a bucket with other genericised\n * errors of the same type+message.\n */\n addError(\n rowNumber: number,\n errorType: string,\n message: string,\n field: string | undefined,\n rowData: ImportJsonValue,\n ): void {\n const fieldKey = field ?? 'GENERAL'\n const signature = `${errorType}:${fieldKey}:${message}`\n\n let pattern = this.patterns.get(signature)\n if (!pattern) {\n if (this.patterns.size >= this.maxPatterns) {\n this.droppedSignatures += 1\n return\n }\n pattern = {\n errorType,\n field: field ?? null,\n message,\n count: 0,\n firstOccurrence: rowNumber,\n lastOccurrence: rowNumber,\n samples: [],\n }\n this.patterns.set(signature, pattern)\n }\n\n pattern.count += 1\n pattern.lastOccurrence = rowNumber\n if (pattern.samples.length < this.maxSamplesPerPattern) {\n pattern.samples.push({ rowNumber, rowData })\n }\n }\n\n /** Total count across every pattern. Counts errors, not patterns. */\n getTotalErrorCount(): number {\n let sum = 0\n for (const p of this.patterns.values()) sum += p.count\n return sum\n }\n\n /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */\n getDistinctPatternCount(): number {\n return this.patterns.size\n }\n\n /**\n * Number of NEW signatures dropped because the map was already at\n * capacity. Surfacing this in the import_run summary tells the operator\n * \"the top-N was saturated — there's a long tail you're not seeing\".\n */\n getDroppedSignatureCount(): number {\n return this.droppedSignatures\n }\n\n /**\n * Top patterns sorted by descending count, capped at `maxPatterns`.\n * Stable secondary order is insertion order (Map iteration order is\n * insertion order; Array.sort is stable in V8).\n */\n getTopPatterns(): ErrorPattern[] {\n const all = Array.from(this.patterns.values()).sort((a, b) => b.count - a.count)\n const totalErrors = all.reduce((sum, p) => sum + p.count, 0)\n return all.map((p) => ({\n errorType: p.errorType,\n field: p.field,\n message: p.message,\n count: p.count,\n firstOccurrence: p.firstOccurrence,\n lastOccurrence: p.lastOccurrence,\n samples: p.samples.slice(),\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n }))\n }\n\n /**\n * Compact JSON-serialisable snapshot for `import_run.errorSummary`.\n * Aside from the patterns array, includes the totals so a reader of\n * just this column doesn't have to re-derive them.\n */\n snapshot(): ErrorTrackerSnapshot {\n return {\n totalErrors: this.getTotalErrorCount(),\n distinctPatterns: this.getDistinctPatternCount(),\n droppedSignatures: this.droppedSignatures,\n patterns: this.getTopPatterns(),\n }\n }\n}\n\nexport interface ErrorTrackerSnapshot {\n totalErrors: number\n distinctPatterns: number\n droppedSignatures: number\n patterns: ErrorPattern[]\n}\n\n/**\n * Merge two independently-produced `ErrorTrackerSnapshot`s into one — e.g.\n * the runner's per-row `warningSummary` plus a worker-level signal computed\n * AFTER the run completes (F010 / #602: the price-drift anomaly check runs\n * in `worker.ts`, after `runImport` returns, using a fresh single-pattern\n * tracker of its own). Patterns concatenate rather than re-dedup: the two\n * snapshots are assumed to use disjoint `errorType` signature spaces (a\n * worker-level tracker uses a type — `price_anomaly` — the runner's own\n * warning aggregation never emits), so counts sum directly with no risk of\n * double-counting one bucket as two.\n */\nexport function mergeErrorTrackerSnapshots(\n a: ErrorTrackerSnapshot,\n b: ErrorTrackerSnapshot,\n): ErrorTrackerSnapshot {\n const totalErrors = a.totalErrors + b.totalErrors\n // Each source snapshot computed `percentage` against its OWN `totalErrors`\n // (see `getTopPatterns` above); the merged total is different, so every\n // pattern's `percentage` must be recomputed against it — a straight\n // concat would leave every value stale (e.g. `20/100=20%` staying `20`\n // once the true merged denominator is `101`). `0` when the merged total\n // is `0`, matching `getTopPatterns`' own zero-total convention.\n const rescale = (p: ErrorPattern): ErrorPattern => ({\n ...p,\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n })\n return {\n totalErrors,\n distinctPatterns: a.distinctPatterns + b.distinctPatterns,\n droppedSignatures: a.droppedSignatures + b.droppedSignatures,\n patterns: [...a.patterns.map(rescale), ...b.patterns.map(rescale)],\n }\n}\n","/**\n * Price-distribution stats + prior-run anomaly detection (F010 / issue #602).\n *\n * The import pipeline's existing anomaly detection — register-handler.ts's\n * \"6b. Header-presence drift check\" — only catches STRUCTURAL drift: a\n * mapped column vanishing (or getting renamed) from the header row. It does\n * NOT catch VALUE drift: a supplier silently flipping their decimal\n * separator (e.g. `1.234,56` → `1234.56`) or thousands separator WITHOUT\n * touching a single column header. Since the saved\n * `import_mapping.numberFormat` (`{decimal, thousands}`) is unchanged,\n * every price in that run parses as a NUMERICALLY VALID but WRONG value —\n * silently corrupting the catalog's prices. Nothing upstream of this module\n * flags it.\n *\n * This module computes a per-run price-distribution snapshot (`PriceStats`)\n * over `PartsDocument.purchase_price_minor` — the DERIVED cost field,\n * rather than the raw net/list price columns, because both the direct-net\n * AND two-file derivation paths in `mapping/parts-schema.ts` (step 4) funnel\n * through it, so a decimal-format flip on EITHER source column surfaces\n * here regardless of which pricing path a supplier's feed uses.\n *\n * Collection (`PriceStatsCollector`) happens in the SAME streaming pass\n * `runner.ts` already uses to build `totals` — one `.record()` call per\n * ES-accepted row, no second pass over the file, no extra DB round-trip.\n * The prior-run comparison (`detectPriceAnomaly`) happens in `worker.ts`\n * AFTER the run completes, because it needs `AdminClient` to look the\n * prior run up — this module stays DB-agnostic, pure data + pure functions.\n */\n\n/** A run's price-distribution snapshot. Values are in MINOR units (cents), matching `PartsDocument.purchase_price_minor`'s own scale. */\nexport interface PriceStats {\n /** ES-accepted rows considered — same accounting as `RunImportResult.rowsSucceeded`. */\n count: number\n /** Fraction (0..1) of `count` whose price sample was absent (e.g. a list-only row with no resolvable purchase cost — the same rows that carry a `no_purchase_price` warning). `0` when `count` is `0`. */\n nullRate: number\n /** Minimum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n min: number | null\n /** Median of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n median: number | null\n /** Maximum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n max: number | null\n}\n\n/** The all-zero/empty snapshot — a run that produced zero ES-accepted rows. */\nexport const EMPTY_PRICE_STATS: PriceStats = {\n count: 0,\n nullRate: 0,\n min: null,\n median: null,\n max: null,\n}\n\n/**\n * Bounded sample cap for the min/median/max computation — the same\n * \"bounded, uniform sample\" philosophy `ErrorTracker` uses top-N patterns\n * for. 50,000 floats is ~400KB, trivial next to the ES bulk-write buffers\n * already in flight for a run this size, and far more than needed for a\n * stable median estimate. A feed with more ES-accepted rows than this cap\n * still gets an EXACT `count` / `nullRate` (those are running counters, not\n * sample-bounded) — only min/median/max become an approximation over a\n * UNIFORM RANDOM sample of `PRICE_SAMPLE_CAP` non-null values, not \"the\n * first N encountered\" (see `record`'s reservoir-sampling note for why\n * order-independence matters specifically for this module's job).\n */\nexport const PRICE_SAMPLE_CAP = 50_000\n\n/**\n * Accumulates price samples during a run's streaming pass. `record` is O(1)\n * (amortized array push below the cap, one RNG draw above it); `snapshot`\n * sorts the retained samples once at the end — cheap relative to the ES\n * bulk-write the run already performs every batch.\n */\nexport class PriceStatsCollector {\n private count = 0\n private nullCount = 0\n private nonNullSeen = 0\n private readonly samples: number[] = []\n\n /**\n * Record one ES-accepted row's price sample. Call exactly once per\n * accepted row (mirrors the `rowsWarned` / `pairs` accounting in\n * `runner.ts`'s flush loop) — `undefined` means \"this row had no price to\n * sample\" and counts toward `nullRate` rather than being skipped.\n *\n * Reservoir sampling (Algorithm R) past the cap, not \"first N wins\": each\n * new non-null value replaces a uniformly-random existing slot with\n * probability `PRICE_SAMPLE_CAP / nonNullSeen`, giving every non-null\n * sample seen so far an equal chance of surviving into the final\n * reservoir. Order-independence matters here specifically — the anomaly\n * this module exists to catch (a supplier's decimal-format flip) could\n * start partway through a feed larger than the cap (e.g. a corrupted\n * re-export appended to an otherwise-normal catalogue), and a first-N\n * sample would never see a single row past the cutover point, silently\n * missing exactly the drift F010 was built to detect.\n */\n record(value: number | undefined): void {\n this.count += 1\n if (value === undefined) {\n this.nullCount += 1\n return\n }\n this.nonNullSeen += 1\n if (this.samples.length < PRICE_SAMPLE_CAP) {\n this.samples.push(value)\n return\n }\n const j = Math.floor(Math.random() * this.nonNullSeen)\n if (j < PRICE_SAMPLE_CAP) this.samples[j] = value\n }\n\n snapshot(): PriceStats {\n if (this.count === 0) return { ...EMPTY_PRICE_STATS }\n const nullRate = this.nullCount / this.count\n if (this.samples.length === 0) {\n return { count: this.count, nullRate, min: null, median: null, max: null }\n }\n const sorted = [...this.samples].sort((a, b) => a - b)\n const min = sorted[0] ?? null\n const max = sorted[sorted.length - 1] ?? null\n const mid = Math.floor(sorted.length / 2)\n let median: number | null\n if (sorted.length % 2 === 0) {\n const lo = sorted[mid - 1]\n const hi = sorted[mid]\n median = lo !== undefined && hi !== undefined ? (lo + hi) / 2 : null\n } else {\n median = sorted[mid] ?? null\n }\n return { count: this.count, nullRate, min, median, max }\n }\n}\n\n/**\n * Anomaly threshold: a run whose median purchase price is >= 10x higher OR\n * <= 1/10 (0.1x) of the prior successful run's median (for the same\n * transform + supplier + brand) is flagged.\n *\n * Chosen generously above the range a real repricing plausibly covers\n * (even an aggressive markup/discount renegotiation is a low-single-digit\n * multiplier — 2x-3x would already be an extreme outlier for a legitimate\n * price change) but comfortably BELOW the ~100x-1000x magnitude a\n * decimal/thousands-separator flip produces for typical EUR part prices:\n * `\"1.234,56\"` (a European-format €1234.56) misparsed with dot-decimal\n * conventions reads as `1.234,56` → `1234.56`, a ~1000x inflation if the\n * true value was e.g. €1.23; a thousands-separator flip on `\"1234,56\"`\n * (true €1234.56) misparsed as `123456` (dropping the decimal entirely) is\n * a ~100x inflation. 10x leaves a wide safety margin against false\n * positives on genuine price changes while still comfortably catching\n * every realistic decimal/thousands-separator flip.\n */\nexport const PRICE_ANOMALY_THRESHOLD_MULTIPLIER = 10\n\nexport interface PriceAnomaly {\n /** `curr.median / prev.median`. */\n ratio: number\n /** The prior run's median (minor units). */\n prevMedian: number\n /** This run's median (minor units). */\n currMedian: number\n}\n\n/**\n * Compare two runs' price stats for a magnitude-scale anomaly. Returns\n * `null` when there's nothing to compare (either side has no median — zero\n * non-null price samples, e.g. a schema/transform that never emits a price\n * sample, or a run with zero ES-accepted rows) or the ratio falls within\n * the safe band.\n */\nexport function detectPriceAnomaly(prev: PriceStats, curr: PriceStats): PriceAnomaly | null {\n if (prev.median === null || curr.median === null) return null\n // A zero/negative median can't produce a meaningful ratio. Shouldn't occur\n // for real minor-unit prices (`toMinorUnits` on a positive price); this is\n // defensive-only so a pathological value can't produce Infinity/NaN below.\n if (prev.median <= 0 || curr.median <= 0) return null\n const ratio = curr.median / prev.median\n if (\n ratio >= PRICE_ANOMALY_THRESHOLD_MULTIPLIER ||\n ratio <= 1 / PRICE_ANOMALY_THRESHOLD_MULTIPLIER\n ) {\n return { ratio, prevMedian: prev.median, currMedian: curr.median }\n }\n return null\n}\n\n/**\n * Narrow an `unknown` value (a `field.json()` column read back through\n * `AdminClient`, typed `JsonValue | undefined` on the DTO) to `PriceStats`,\n * or `null` when it doesn't structurally match — e.g. a run written before\n * this field existed, or a run whose transform never emitted a price\n * sample so `min`/`median`/`max` serialised as `null`. Defensive, matching\n * the `params` narrowing pattern already used in `worker.ts`.\n */\nexport function parsePriceStats(value: unknown): PriceStats | null {\n if (typeof value !== 'object' || value === null) return null\n const v = value as Record<string, unknown>\n if (typeof v.count !== 'number' || typeof v.nullRate !== 'number') return null\n return {\n count: v.count,\n nullRate: v.nullRate,\n min: typeof v.min === 'number' ? v.min : null,\n median: typeof v.median === 'number' ? v.median : null,\n max: typeof v.max === 'number' ? v.max : null,\n }\n}\n","/**\n * One-shot importer: stream rows → transform → batched bulk-write to ES,\n * accumulating per-row errors into `ErrorTracker` and reporting progress\n * to the queue every batch.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope):\n * - **Batch size 1000.** Matches giga-test precedent. Configurable for\n * integration tests that don't want a 1k row floor.\n * - **No resumability and no automatic retries.** The `imports:run` queue\n * job is registered with `defaultRetries: 0` (see `worker.ts`) so a\n * failed handler does NOT re-enqueue itself — re-running a multi-batch\n * import against the same `import_run.id` while the previous attempt\n * may still be writing is a footgun (duplicate batches, double-counted\n * progress). Operator retries by creating a NEW `import_run` row.\n * - **No per-supplier transform plugin.** The runner takes a single\n * `RowTransform<TDoc>` from the registry and applies it to every row;\n * PR 8 may diverge but only by registering a different transform name.\n * - **Direct `bulkUpsert` into the live aliased index** (D6 alias is\n * set up by PR 4's `ensureAliasedIndex`; the importer doesn't reindex).\n *\n * Per D21 (sanctioned bulk path): this runner intentionally bypasses\n * AdminClient and entity hooks. Per-batch audit / observability lives\n * on the surrounding `import_run` row + queue progress, NOT per-row.\n */\n\nimport {\n type BulkIndexResult,\n bulkPartialUpdate,\n bulkUpsert,\n type EsClientLike,\n} from '@murumets-ee/search-elasticsearch'\nimport { ErrorTracker } from './error-tracker.js'\nimport { type PriceStats, PriceStatsCollector } from './price-stats.js'\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\nimport type {\n DirectionalPair,\n DocPatch,\n RowTransform,\n RowWarning,\n TransformContext,\n} from './transform.js'\n\n/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */\nexport const DEFAULT_BATCH_SIZE = 1000\n\nexport interface RunImportOptions<TDoc> {\n /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */\n importRunId: string\n /** Operator-supplied label for the run. Forwarded to the transform context. */\n runLabel: string\n /** Opaque per-run params copied from `import_run.params`. */\n params: Record<string, unknown>\n /** Transform applied to every parsed row. */\n transform: RowTransform<TDoc>\n /** Feed reader options — file path + format (csv/tsv/xlsx) + delimiter/header config. */\n feed: ReadFeedOptions\n /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */\n esClient: EsClientLike\n /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */\n esIndex: string\n /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */\n batchSize?: number\n /**\n * Callback invoked after every batch. The handler in `worker.ts`\n * forwards this to `ctx.updateProgress` for the queue UI; tests\n * inspect it directly. Synchronous + cheap so a slow callback can't\n * back-pressure the importer.\n */\n onProgress?: (progress: ImportRunProgress) => void\n /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */\n rowLimit?: number\n /** Abort signal threaded into the underlying ES client request — cooperative cancel. */\n signal?: AbortSignal\n /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */\n errorTracker?: ErrorTracker\n /**\n * Optional separate tracker for NON-FATAL row warnings (commerce-demo PR4 —\n * `no_purchase_price`). Distinct from {@link errorTracker} so warnings never\n * inflate `totals.failed` / the error summary: a warned row is a SUCCESS whose\n * doc is indexed. Default: a fresh tracker with the same caps.\n */\n warningTracker?: ErrorTracker\n /**\n * Optional price-stats collector (F010 / #602) — accumulates a\n * min/median/max/nullRate distribution over ES-accepted rows' optional\n * `priceSample`s in the SAME streaming pass that builds `totals`. Default:\n * a fresh collector. Tests can inject their own to inspect it directly —\n * mirrors the {@link errorTracker} / {@link warningTracker} DI shape.\n */\n priceStats?: PriceStatsCollector\n /**\n * Optional structural logger for per-batch telemetry. Receives\n * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after\n * every flush, plus a one-shot `started` and `finished` event. The\n * worker forwards `app.logger.child({ pkg: 'imports' })`; tests\n * leave it undefined.\n *\n * Why: without per-batch visibility, a stalled run looks identical\n * to \"first batch in progress\" forever — the only feedback is the\n * queue's `progress` jsonb (which writes after each batch) and the\n * eventual `succeeded`/`failed` job event. Adding this lets the\n * operator tail the worker log and immediately see whether ES is\n * rate-limiting, the transform is rejecting every row, or the\n * stream is actually progressing.\n */\n logger?: {\n info: (data: Record<string, unknown>, msg: string) => void\n warn: (data: Record<string, unknown>, msg: string) => void\n }\n}\n\n/**\n * Progress payload written to `toolkit_jobs.progress` after every batch.\n * Caps + flush rules live on the queue's `updateProgress` debounce —\n * callers don't need to throttle.\n */\nexport interface ImportRunProgress {\n rowsRead: number\n rowsSucceeded: number\n rowsFailed: number\n rowsSkipped: number\n /** Rows indexed WITH a non-fatal warning (commerce-demo PR4 — `no_purchase_price`). */\n rowsWarned: number\n batchesCompleted: number\n /** Wall-clock seconds since the runner started. */\n elapsedSeconds: number\n /** Rows / second, computed at every batch. */\n rowsPerSecond: number\n /** Distinct error patterns currently held by the tracker. Saturates at the cap. */\n distinctErrorPatterns: number\n /**\n * Total data-row count from a pre-pass scan, when the worker computed one.\n * Lets the UI render `processed / total` percent. Absent when the worker\n * skipped the count (file too large for the count budget, count failed,\n * etc.) — UI must treat it as optional.\n */\n totalRows?: number\n}\n\n/**\n * Final result returned by {@link runImport}. The handler writes these\n * onto the `import_run` row alongside the ErrorTracker snapshot.\n */\nexport interface RunImportResult {\n /** Total rows read from the file (excludes skipped empty lines). */\n rowsRead: number\n /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */\n rowsSucceeded: number\n /**\n * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES\n * rejected on bulk-write (per-doc failure). Both are aggregated by\n * `errorTracker` for the import_run summary.\n */\n rowsFailed: number\n /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */\n rowsSkipped: number\n /**\n * Rows that succeeded (doc indexed) but carried ≥1 non-fatal warning —\n * counted SEPARATELY from `rowsSucceeded`/`rowsFailed` (a warned row is also\n * counted in `rowsSucceeded`). commerce-demo PR4: `no_purchase_price`.\n */\n rowsWarned: number\n /** Number of `bulkUpsert` calls made. */\n batchesCompleted: number\n /** Final value of {@link ErrorTracker.snapshot}. */\n errors: ReturnType<ErrorTracker['snapshot']>\n /** Aggregated NON-FATAL warnings (commerce-demo PR4). Empty when no row warned. */\n warnings: ReturnType<ErrorTracker['snapshot']>\n /**\n * Directional supersession pairs harvested as a side-output (commerce-demo PR6,\n * SD007). Buffered in memory from ES-ACCEPTED rows only (same accounting as\n * warnings — a row whose doc was rejected by ES contributes no pair). Empty unless\n * a schema emits them (the `parts` schema, when a row's `superseding_code` is set).\n * The worker hands these to an injected `pairSink` AFTER the run for the commerce\n * replacement-merge engine; the runner itself never writes the consumer's tables.\n */\n pairs: readonly DirectionalPair[]\n /**\n * Doc-patches applied by the phase-2 pass (commerce-demo PR7): a `<code>*S`\n * core-fee row is not a saleable doc — it emits a {@link DocPatch} that merges its\n * deposit onto the PARENT part's `core_charge_minor`. The runner buffers these\n * during the stream (sparse) and applies them via `bulkPartialUpdate` AFTER the ES\n * pass. `patchesApplied` = targets the cluster merged; `patchesOrphaned` = patches\n * whose parent genuinely wasn't in the index (a `document_missing`/404 → an\n * `orphan_core_charge` warning). An INFRA failure (whole-batch transport error, or a\n * non-404 per-doc failure) is NOT counted as orphaned — it lands in a distinct\n * `patch_attach_failed` warning bucket so it can't masquerade as a data orphan. Zero\n * unless a schema emits patches.\n */\n patchesApplied: number\n patchesOrphaned: number\n /**\n * Price-distribution snapshot (F010 / #602) accumulated from ES-accepted\n * rows' `priceSample`s during this same streaming pass. See `price-stats.ts`.\n * `count` is `0` (all fields `null`/`0`) for a run whose schema never emits\n * a price sample.\n */\n priceStats: PriceStats\n}\n\n/**\n * Apply the runner against a feed file. Stops on rowLimit OR end-of-file\n * OR if `signal` aborts. Throws if the streaming reader / ES client\n * throws — caller (the queue handler) catches that and writes\n * `import_run.status = 'failed'` with the error message in\n * `errorSummary.fatal`.\n */\nexport async function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult> {\n const {\n importRunId,\n runLabel,\n params,\n transform,\n feed,\n esClient,\n esIndex,\n batchSize = DEFAULT_BATCH_SIZE,\n onProgress,\n rowLimit,\n signal,\n errorTracker = new ErrorTracker(),\n warningTracker = new ErrorTracker(),\n priceStats: priceStatsCollector = new PriceStatsCollector(),\n logger,\n } = options\n\n if (batchSize < 1) {\n throw new Error(`batchSize must be >= 1 (got ${batchSize})`)\n }\n\n const startedAt = Date.now()\n logger?.info({ importRunId, runLabel, batchSize, esIndex }, 'imports.runner: started')\n let rowsRead = 0\n let rowsSucceeded = 0\n let rowsFailed = 0\n let rowsSkipped = 0\n let rowsWarned = 0\n let batchesCompleted = 0\n\n // Side-output buffer (commerce-demo PR6, SD007): directional supersession pairs\n // accumulated from ES-ACCEPTED rows across the whole run. An array append per\n // sparse superseded row — it never blocks the `bulkUpsert` loop. Handed to the\n // injected `pairSink` by the worker AFTER the run (read-once / merge-after).\n const collectedPairs: DirectionalPair[] = []\n\n // Side-output buffer (commerce-demo PR7): doc-patches from `*S` core-fee rows —\n // each attaches a core charge onto a DIFFERENT (parent) doc. Collected off the\n // `skip` result (a `*S` row is never a saleable doc), sparse, applied in a phase-2\n // `bulkPartialUpdate` after the ES pass (below) — order-independent because ES\n // `update` is realtime by `_id`.\n const collectedPatches: DocPatch<TDoc>[] = []\n\n // Warnings + pairs ride on the pending item (with the raw row for the warning\n // sample) so they're accounted at FLUSH time — only for rows ES actually accepted.\n // A warned row is by contract a SUCCESSFUL row, so counting it at push time (before\n // the bulk response) would wrongly keep a later-ES-rejected row in rowsWarned /\n // warnings; pairs follow the same accepted-rows-only rule.\n let pending: Array<{\n id: string\n doc: TDoc\n rowNumber: number\n rowData: Record<string, string>\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n priceSample?: number\n }> = []\n\n const flush = async (): Promise<void> => {\n if (pending.length === 0) return\n const batch = pending\n pending = []\n const flushStartedAt = Date.now()\n let result: BulkIndexResult\n try {\n result = await bulkUpsert<TDoc>(esClient, {\n index: esIndex,\n docs: batch.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // Cluster- or transport-level failure — the whole batch is\n // unaccounted for. Distinguish abort (operator-driven cancel) from\n // a real cluster failure so the errorSummary doesn't mislabel a\n // cancelled run as broken cluster connectivity.\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n const errorType = isAbort ? 'aborted' : 'bulk_request_failed'\n const reason = err instanceof Error ? err.message : String(err)\n for (const { rowNumber } of batch) {\n errorTracker.addError(rowNumber, errorType, reason, undefined, null)\n }\n rowsFailed += batch.length\n throw err\n }\n\n rowsSucceeded += result.succeeded\n rowsFailed += result.failures.length\n batchesCompleted += 1\n\n const flushMs = Date.now() - flushStartedAt\n const elapsedSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n batch: batchesCompleted,\n flushMs,\n submitted: batch.length,\n succeeded: result.succeeded,\n failed: result.failures.length,\n rowsRead,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n },\n 'imports.runner: batch flushed',\n )\n\n if (result.failures.length > 0) {\n // Map each ES failure back to its source row via `id`. The bulk\n // response order matches the request order, but ES doesn't promise\n // that; matching by `id` is the safe path. PoC volume is small\n // enough that the O(failures × batch) cost is irrelevant.\n const byId = new Map(batch.map((b) => [b.id, b.rowNumber]))\n for (const fail of result.failures) {\n const rowNumber = byId.get(fail.id) ?? -1\n errorTracker.addError(rowNumber, fail.type, fail.reason, undefined, { id: fail.id })\n }\n }\n\n // Account non-fatal warnings ONLY for rows ES accepted (commerce-demo PR4) — a\n // warned row is by contract a SUCCESSFUL row, so a row that warned AND then failed\n // the bulk write must NOT land in rowsWarned/warnings (it's already in rowsFailed).\n const failedIds = new Set(result.failures.map((f) => f.id))\n for (const item of batch) {\n if (!item.warnings || item.warnings.length === 0) continue\n if (failedIds.has(item.id)) continue\n rowsWarned += 1\n for (const w of item.warnings) {\n warningTracker.addError(item.rowNumber, w.warningType, w.message, w.field, item.rowData)\n }\n }\n\n // Buffer directional pairs for rows ES accepted (commerce-demo PR6, SD007) — same\n // accepted-rows-only rule as warnings, so a pair from a doc ES rejected is dropped.\n for (const item of batch) {\n if (!item.pairs || item.pairs.length === 0) continue\n if (failedIds.has(item.id)) continue\n collectedPairs.push(...item.pairs)\n }\n\n // Price-distribution sampling (F010 / #602) — same accepted-rows-only rule as\n // warnings/pairs: a row ES rejected contributes no sample. Every ES-accepted row\n // records exactly once (`undefined` counts toward `nullRate` rather than being\n // skipped), so `priceStats.count` tracks `rowsSucceeded` exactly.\n for (const item of batch) {\n if (failedIds.has(item.id)) continue\n priceStatsCollector.record(item.priceSample)\n }\n\n if (onProgress) {\n const elapsedSeconds = (Date.now() - startedAt) / 1000\n onProgress({\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n elapsedSeconds,\n rowsPerSecond: elapsedSeconds > 0 ? rowsRead / elapsedSeconds : 0,\n distinctErrorPatterns: errorTracker.getDistinctPatternCount(),\n })\n }\n }\n\n // Streaming heartbeat — sample every Nth row so the operator sees the\n // streamer is alive even when a transform-rejected file would prevent\n // any flush from firing. Without this, a feed that's 100% rejected\n // looks identical to \"stuck\" until end-of-file. Sample frequency is\n // batchSize so the cadence matches the natural batch rhythm.\n const streamSampleEvery = batchSize\n\n // First-error capture per error type — surface what's actually rejecting\n // the rows when 100% of a feed is rejected. Without this, the only signal\n // is `errorTracker.snapshot()` which only writes to import_run.errorSummary\n // at the END of the run. Mid-run we want to know NOW.\n const firstErrorByType = new Map<string, { rowNumber: number; message: string }>()\n\n for await (const { rowNumber, row } of readFeed(feed)) {\n if (signal?.aborted) break\n if (rowLimit !== undefined && rowsRead >= rowLimit) break\n rowsRead += 1\n\n if (rowsRead === 1) {\n logger?.info(\n { importRunId, rowNumber, sampleKeys: Object.keys(row) },\n 'imports.runner: STREAM_FIRST_ROW',\n )\n }\n if (rowsRead % streamSampleEvery === 0) {\n const elapsedSec = (Date.now() - startedAt) / 1000\n // Telemetry only — no row content, no error sample text.\n // PR #262 review (M-5): the prior `firstErrors:\n // Object.fromEntries(firstErrorByType)` payload included the\n // raw error message captured from the transform, which for the\n // carmaker feed embeds supplier codes + price fragments. The\n // detailed first-error breakdown is already persisted on\n // `import_run.errorSummary` via `errorTracker.snapshot()` —\n // operators inspect it there, not in the worker log.\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n pending: pending.length,\n elapsedSec: Math.round(elapsedSec * 10) / 10,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n distinctFirstErrorTypes: firstErrorByType.size,\n },\n 'imports.runner: STREAM_TICK',\n )\n }\n\n const ctx: TransformContext = { importRunId, params, runLabel, rowNumber }\n let result: Awaited<ReturnType<typeof transform>>\n try {\n result = await transform(row, ctx)\n } catch (err) {\n // A throw from the transform is a programmer error — surface it as\n // a row-level error so the run can continue. (If the bug is\n // catastrophic, the operator sees the same message repeated and\n // can stop the run.)\n const reason = err instanceof Error ? err.message : String(err)\n errorTracker.addError(rowNumber, 'transform_threw', reason, undefined, row)\n if (!firstErrorByType.has('transform_threw')) {\n firstErrorByType.set('transform_threw', { rowNumber, message: reason })\n // PR #262 review (M-5): drop the `row` payload from the\n // log line — supplier feeds carry pricing + internal codes.\n // The full row is on `import_run.errorSummary.samples` for\n // post-mortem review by operators with view permission.\n logger?.warn(\n { importRunId, rowNumber, errorType: 'transform_threw', message: reason },\n 'imports.runner: FIRST_ERROR transform_threw',\n )\n }\n rowsFailed += 1\n continue\n }\n\n if (result.kind === 'skip') {\n rowsSkipped += 1\n // A `*S` core-fee row skips the saleable index but may carry a doc-patch\n // (commerce-demo PR7) — buffer it for the phase-2 attach. Unlike pairs, a\n // patch has no ES write of its own to gate on, so it's collected here\n // unconditionally; its success is decided by the phase-2 update.\n if (result.patches && result.patches.length > 0) {\n collectedPatches.push(...result.patches)\n }\n continue\n }\n if (result.kind === 'error') {\n errorTracker.addError(\n rowNumber,\n result.error.errorType,\n result.error.message,\n result.error.field,\n row,\n )\n if (!firstErrorByType.has(result.error.errorType)) {\n firstErrorByType.set(result.error.errorType, {\n rowNumber,\n message: result.error.message,\n })\n logger?.warn(\n {\n importRunId,\n rowNumber,\n errorType: result.error.errorType,\n field: result.error.field,\n message: result.error.message,\n },\n `imports.runner: FIRST_ERROR ${result.error.errorType}`,\n )\n }\n rowsFailed += 1\n continue\n }\n\n // Non-fatal warnings ride on a SUCCESS — the doc is still indexed\n // (commerce-demo PR4: `no_purchase_price`). Carry them (+ the raw row for the\n // sample) on the pending item; the flush aggregates them into the separate\n // `warningTracker` AFTER the bulk response confirms the row was accepted, so a\n // warned-then-ES-rejected row never inflates rowsWarned / the warning summary.\n pending.push({\n id: result.id,\n doc: result.doc,\n rowNumber,\n rowData: row,\n ...(result.warnings && result.warnings.length > 0 && { warnings: result.warnings }),\n ...(result.pairs && result.pairs.length > 0 && { pairs: result.pairs }),\n ...(result.priceSample !== undefined && { priceSample: result.priceSample }),\n })\n if (pending.length >= batchSize) {\n await flush()\n }\n }\n\n await flush()\n\n // Phase 2 (commerce-demo PR7): attach the buffered `*S` core-charge patches onto\n // their PARENT docs. Runs AFTER the whole stream so every parent that exists in\n // this run is already indexed; ES `update` is realtime by `_id`, so the merge\n // lands regardless of whether the parent streamed before or after its `*S` row\n // (order-independent). Patches are sparse (only reman parts carry `*S`), but batch\n // anyway for a giant catalogue. A patch whose parent isn't in the index comes back\n // as a per-doc failure (`document_missing_exception`) → an `orphan_core_charge`\n // warning, never a phantom doc, never a silent drop (D004). A cluster failure of\n // the whole batch is logged as a warning too — the saleable docs already indexed;\n // only the deferred core-charge attach didn't happen — so we never fail the run\n // over it.\n let patchesApplied = 0\n let patchesOrphaned = 0\n for (let i = 0; i < collectedPatches.length; i += batchSize) {\n // Operator-driven cancel BETWEEN batches: THROW rather than break, so a run\n // cancelled after phase 1 is not marked `succeeded` with core charges left\n // unattached. Mirrors phase-1's flush, which also re-throws on abort. The queue\n // handler catches this and marks the run failed.\n if (signal?.aborted) {\n throw new Error('imports: run aborted during core-charge attach (phase 2)')\n }\n const slice = collectedPatches.slice(i, i + batchSize)\n let updateResult: BulkIndexResult\n try {\n updateResult = await bulkPartialUpdate<TDoc>(esClient, {\n index: esIndex,\n updates: slice.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // An abort surfacing as an AbortError from the ES client is a cancel, NOT an\n // attach failure — re-throw so the run fails (same as phase 1), never a silent\n // \"succeeded with patches unapplied\".\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n if (isAbort) throw err\n // Whole-batch transport/cluster failure (ES down, timeout). The parents may\n // well exist — the attach simply didn't happen — so this is an INFRA failure,\n // NOT a data orphan: it gets the distinct `patch_attach_failed` bucket and is\n // NOT counted in patchesOrphaned. Best-effort: the saleable docs already\n // indexed, and a re-import re-attaches, so we warn + continue rather than fail.\n const reason = err instanceof Error ? err.message : String(err)\n for (const patch of slice) {\n warningTracker.addError(-1, 'patch_attach_failed', reason, patch.orphanWarning?.field, {\n target: patch.id,\n })\n }\n continue\n }\n patchesApplied += updateResult.succeeded\n if (updateResult.failures.length > 0) {\n const byId = new Map(slice.map((p) => [p.id, p]))\n for (const fail of updateResult.failures) {\n const w = byId.get(fail.id)?.orphanWarning\n // A missing target (`document_missing_exception` / 404) is a genuine data\n // orphan — the referenced parent isn't in this import (D004: warn, never a\n // phantom doc). A stable message (from the patch's orphanWarning) aggregates\n // orphans; the missing parent id rides in the sample. Any OTHER per-doc\n // failure (mapper error, etc.) is an infra/attach failure kept in a distinct\n // bucket so it can't masquerade as a data orphan.\n const isOrphan = fail.status === 404 || fail.type === 'document_missing_exception'\n if (isOrphan) {\n patchesOrphaned += 1\n warningTracker.addError(\n -1,\n w?.warningType ?? 'orphan_patch',\n w?.message ?? fail.reason,\n w?.field,\n {\n target: fail.id,\n },\n )\n } else {\n warningTracker.addError(-1, 'patch_attach_failed', fail.reason, w?.field, {\n target: fail.id,\n })\n }\n }\n }\n }\n\n const totalSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n patchesApplied,\n patchesOrphaned,\n totalSec: Math.round(totalSec * 10) / 10,\n rowsPerSecond: totalSec > 0 ? Math.round(rowsRead / totalSec) : 0,\n },\n 'imports.runner: finished',\n )\n\n return {\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n errors: errorTracker.snapshot(),\n warnings: warningTracker.snapshot(),\n pairs: collectedPairs,\n patchesApplied,\n patchesOrphaned,\n priceStats: priceStatsCollector.snapshot(),\n }\n}\n"],"mappings":"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"}
package/dist/worker.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{a as e,r as t}from"./read-feed-DGjqejw3.mjs";import{c as n,d as r,f as i,n as a,s as o}from"./runner-C9GIe5Dr.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};
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
2
  //# sourceMappingURL=worker.mjs.map
@@ -0,0 +1,2 @@
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xlsx-reader-D9Ntxm29.mjs","names":[],"sources":["../src/xlsx-reader.ts"],"sourcesContent":["/**\n * Streaming XLSX reader (commerce-demo phase 02, D005) — the exceljs branch of\n * the feed reader. Yields the SAME `AsyncIterable<{ rowNumber, row }>` contract as\n * the csv-parse `streamFeed`, so the runner / transform layer downstream is\n * format-agnostic.\n *\n * **exceljs over SheetJS** (R02): we parse operator-uploaded spreadsheets — the\n * exact threat model of the unfixed npm `xlsx` prototype-pollution CVE\n * (CVE-2023-30533). exceljs is MIT, ships its own types, has a real streaming\n * `WorkbookReader`, and carries no equivalent advisory.\n *\n * **exceljs is `await import()`-ed**, never statically imported, so it stays out\n * of this package's static module graph — heavy + Node-only, it must not be\n * inlinable into any `*-ui` server-pages bundle (CLAUDE.md bundle budgets). The\n * `read-feed` dispatcher additionally only loads THIS module for `format: 'xlsx'`.\n */\n\nimport { type FileHandle, open } from 'node:fs/promises'\nimport { Duplex } from 'node:stream'\nimport type { CellValue } from 'exceljs'\nimport { assertUniqueHeaders, type StreamFeedRow } from './streaming.js'\n\n/**\n * Thrown by {@link streamXlsx} when the configured header row parses to zero\n * readable columns — the signature of the silent-corruption defect documented\n * on the guard below.\n *\n * A distinct CLASS rather than a bare `Error` because the retroactive audit\n * (`auditEmptyHeaderRuns`, #842) has to tell \"this file's header is\n * unreadable\" apart from every other way reading a spreadsheet can fail\n * (missing object, unreadable zip, wrong sheet index). Matching on the message\n * would make that classification a regex over prose that anyone is free to\n * reword — which is exactly the kind of detector that silently stops detecting.\n * The message text is unchanged from the original throw.\n */\nexport class XlsxEmptyHeaderError extends Error {\n /** 1-based worksheet row the empty header was read from. */\n readonly headerRowNumber: number\n\n constructor(headerRowNumber: number) {\n super(\n `xlsx header row ${headerRowNumber} has no readable columns — the file may be corrupt, ` +\n 'or its shared strings failed to load. Refusing rather than importing empty rows.',\n )\n this.name = 'XlsxEmptyHeaderError'\n this.headerRowNumber = headerRowNumber\n }\n}\n\n/**\n * Thrown by {@link streamXlsx} when the path resolves to something that is not a\n * regular file — a directory, most often. `open()` SUCCEEDS on a directory on\n * Linux (POSIX allows `O_RDONLY` on one) and only the read fails, so this is a\n * check the reader has to make itself.\n *\n * Typed for the same reason {@link XlsxEmptyHeaderError} is: the #842 audit has\n * to tell \"the evidence is unreadable\" from \"the evidence is readable and bad\",\n * and matching prose is how a classifier quietly stops classifying.\n */\nexport class XlsxNotAFileError extends Error {\n constructor(filePath: string) {\n super(`xlsx path \"${filePath}\" is not a regular file`)\n this.name = 'XlsxNotAFileError'\n }\n}\n\nexport interface XlsxReadOptions {\n /** Path to the .xlsx file on local disk. */\n filePath: string\n /** First row is the header (column names). `false` → positional `{ \"0\": … }` keys. Default `true`. */\n hasHeader?: boolean\n /** 0-based index of the header row (rows before it are skipped). Default `0`. */\n headerRowIndex?: number\n /** Worksheet to read, as a 1-based index string. Omit → the first worksheet. */\n sheet?: string\n}\n\n/** Convert one exceljs cell value to a plain string (feeds are text/number cells). */\nfunction cellToString(v: CellValue): string {\n if (v === null || v === undefined) return ''\n if (typeof v === 'string') return v\n if (typeof v === 'number' || typeof v === 'boolean') return String(v)\n if (v instanceof Date) return v.toISOString()\n // Object cell values: rich text, hyperlink, formula result, error.\n if ('richText' in v && Array.isArray(v.richText)) {\n return v.richText.map((r) => r.text).join('')\n }\n if ('text' in v && typeof v.text === 'string') return v.text\n if ('result' in v && v.result !== undefined && v.result !== null) {\n const r = v.result\n if (r instanceof Date) return r.toISOString()\n // A nested error object (formula error) surfaces as empty; primitives stringify.\n return typeof r === 'object' ? '' : String(r)\n }\n return ''\n}\n\n/** Normalise an exceljs `row.values` (1-indexed array OR object form) to a 0-indexed string[]. */\nfunction rowCells(values: CellValue[] | { [key: string]: CellValue }): string[] {\n const out: string[] = []\n if (Array.isArray(values)) {\n // exceljs row arrays are 1-indexed (`values[0]` is empty); shift to 0-based.\n for (let i = 1; i < values.length; i += 1) out[i - 1] = cellToString(values[i])\n } else {\n for (const [k, v] of Object.entries(values)) {\n const idx = Number.parseInt(k, 10)\n if (!Number.isNaN(idx) && idx >= 1) out[idx - 1] = cellToString(v)\n }\n }\n // Fill any holes (sparse rows) with empty strings — the contract is \"missing\n // cell = ''\", matching streamFeed.\n for (let i = 0; i < out.length; i += 1) if (out[i] === undefined) out[i] = ''\n return out\n}\n\n/**\n * Stop unzipper's PREMATURE `'end'` from ending exceljs's entry loop while zip\n * entries are still buffered — the cause of the \"clean workbook reads as\n * corrupt\" flake (plan frontend-delivery F067 / F008), and a production hazard\n * for the same reason the `reader.model` seed below is one.\n *\n * The mechanism, read from source and reproduced deterministically:\n *\n * 1. unzipper 0.10's `Parse` (what exceljs pipes our source into,\n * `workbook-reader.js:81`) re-emits its WRITABLE side's `'finish'` as\n * `'end'` (`unzipper/lib/parse.js`, constructor) — i.e. \"all input bytes\n * consumed\", not \"every entry delivered\". Small entries fit their\n * PassThrough buffers, so `Parse` races through the whole zip, pushing\n * entry objects into its readable buffer, regardless of the consumer.\n * 2. exceljs's `iterateStream` PAUSES the zip while it handles an entry, and\n * for a worksheet that precedes `sharedStrings.xml` (exceljs's own write\n * order, and Excel's) \"handling\" is a `tmp.file()` + write — filesystem\n * round-trips. It treats the first `'end'` it hears as the end of the zip\n * and exits with the rest of the entries still sitting in the buffer.\n * 3. So when the temp-file write is slower than inflating the remaining\n * entries, `xl/sharedStrings.xml` is never parsed. Every string cell then\n * comes back as `{ sharedString: n }` (exceljs's no-table shape),\n * `cellToString` renders it `''`, and the header guard in `streamXlsx`\n * refuses a perfectly good file as `XlsxEmptyHeaderError`.\n *\n * Measured by delaying `tmp.file` inside exceljs: 0-1ms reads correctly,\n * 5ms+ loses the shared strings every time. A loaded CI runner or a busy\n * queue worker supplies those milliseconds; the regression test supplies them\n * on purpose (`xlsx-reader.premature-end.test.ts`).\n *\n * The fix drops an `'end'` only while the zip's readable buffer still holds\n * undelivered entries. By the time `Parse` finishes its writable side it has\n * reached the end-of-central-directory record and called `push(null)`, so\n * Node emits the REAL `'end'` itself once those entries have been read — this\n * delays the event, it never loses one. An `'end'` with an empty buffer passes\n * straight through, so a read that was already correct is untouched.\n *\n * Not a `pnpm patch` of exceljs/unzipper: a patch lives in THIS workspace's\n * install only, and `@murumets-ee/imports` is published — every consumer\n * would keep the defect. If a future exceljs stops piping into a `Duplex`,\n * the hook simply does not apply and the regression test goes red.\n */\nfunction holdPrematureZipEnd(source: ReturnType<FileHandle['createReadStream']>): void {\n const pipe = source.pipe.bind(source)\n source.pipe = <T extends NodeJS.WritableStream>(\n destination: T,\n options?: { end?: boolean },\n ): T => {\n if (destination instanceof Duplex) {\n const zip = destination\n const emit = zip.emit.bind(zip)\n zip.emit = (event: string | symbol, ...args: unknown[]): boolean => {\n if (event === 'end' && zip.readableLength > 0) return false\n return emit(event, ...args)\n }\n }\n return pipe(destination, options)\n }\n}\n\n/**\n * Async-iterable over the data rows of an .xlsx file. `rowNumber` is the 1-based\n * DATA row index (header excluded). Cell values are coerced to strings so the\n * mapping engine's per-column transforms (which expect raw string cells) apply\n * uniformly across csv/tsv/xlsx.\n */\nexport async function* streamXlsx(options: XlsxReadOptions): AsyncIterable<StreamFeedRow> {\n const { filePath, hasHeader = true, headerRowIndex = 0, sheet } = options\n const ExcelJS = await import('exceljs')\n\n // `WorksheetReader` doesn't expose its sheet id, so count emitted worksheets\n // (1-based) to match the configured `sheet`. Default: the first worksheet.\n // A provided-but-invalid sheet FAILS FAST rather than silently reading sheet 1\n // — and before we touch the filesystem, so a config typo costs no I/O.\n let targetSheetIndex: number | undefined\n if (sheet !== undefined) {\n targetSheetIndex = Number.parseInt(sheet, 10)\n if (Number.isNaN(targetSheetIndex) || targetSheetIndex < 1) {\n throw new Error(`Invalid xlsx sheet \"${sheet}\" — expected a 1-based worksheet index`)\n }\n }\n\n // OPEN THE FILE OURSELVES rather than handing exceljs a path, because a path\n // makes every filesystem failure an UNCAUGHT EXCEPTION that kills the process\n // instead of a rejection the caller can handle.\n //\n // `_getStream` does `fs.createReadStream(input)` for a string input and then\n // `stream.pipe(zip)` (`workbook-reader.js:38,81`). `pipe()` does not forward\n // SOURCE errors, and exceljs registers no `error` listener on that stream — so\n // an ENOENT / EACCES / EISDIR surfaces as `Unhandled 'error' event`. Verified\n // by execution, not inference: all three crash the process outright. In the\n // queue worker that kills the job mid-flight instead of routing through\n // `markFailed`; in the #842 audit one unreadable file discards the whole sweep.\n //\n // An fd fixes it at the root rather than patching the symptom:\n // - `open()` rejects normally on ENOENT and EACCES.\n // - a DIRECTORY still opens on Linux (checked — O_RDONLY on a dir succeeds\n // and only the read gives EISDIR), so the `isFile()` check below is\n // load-bearing. It runs against the fd, so it cannot be raced.\n // - once the fd is held, the inode survives `unlink` (checked: the read\n // still returns the bytes). That closes the window against the\n // `@every 1h` retention sweep, which deletes exactly these files and which\n // the audit is documented as racing.\n const handle = await open(filePath, 'r')\n // Held so the outer `finally` can destroy it BEFORE the fd goes away. With\n // `autoClose: false` nothing tears this stream down except reaching the end,\n // and the drain below deliberately swallows errors — so a malformed trailing\n // worksheet can leave the source mid-read while `handle.close()` invalidates\n // the descriptor underneath it. exceljs registers no `error` listener on the\n // stream it pipes (the whole reason this function opens the file itself), so\n // that EBADF would arrive as an uncaught event: the exact failure mode the fd\n // was introduced to eliminate, re-entering through the back door.\n let source: ReturnType<typeof handle.createReadStream> | undefined\n try {\n const stats = await handle.stat()\n if (!stats.isFile()) {\n throw new XlsxNotAFileError(filePath)\n }\n\n source = handle.createReadStream({ autoClose: false })\n holdPrematureZipEnd(source)\n const reader = new ExcelJS.stream.xlsx.WorkbookReader(source, {\n worksheets: 'emit',\n sharedStrings: 'cache',\n styles: 'ignore',\n hyperlinks: 'ignore',\n entries: 'ignore',\n })\n\n // Seed `model` so exceljs cannot dereference it before it has parsed\n // `xl/workbook.xml` — an UPSTREAM defect, and the cause of the xlsx-reader\n // \"flake\" that failed CI intermittently for months (#619, #835).\n //\n // Its streaming reader parses a worksheet INLINE, rather than deferring it to\n // a temp file, when `this.sharedStrings && this.workbookRels` are both set as\n // the entry arrives (`workbook-reader.js:109`). Neither implies\n // `xl/workbook.xml` has been read. The inline path then does\n // `matchingRel && (this.model.sheets || []).find(…)` (`:303`) — guarding\n // `this.workbookRels` on the line above and `this.model.sheets` not at all —\n // so any workbook whose zip orders `sharedStrings.xml` before the worksheet,\n // with `workbook.xml` after it, throws `Cannot read properties of undefined\n // (reading 'sheets')`.\n //\n // That is a PRODUCTION hazard, not a test artifact: this function reads\n // operator-uploaded spreadsheets and the writer chooses the entry order. It\n // presented as flakiness only because the fixtures our own tests write happen\n // to sit on the safe side of the condition.\n //\n // An empty `sheets` makes the lookup miss, so the worksheet keeps its default\n // id/name/state until `_parseWorkbook` overwrites `model` with the real one a\n // few entries later — which is exactly what the deferred path already does.\n // Preferred over a `pnpm patch` (exceljs 4.4.0 is the latest release, so there\n // is no upstream fix to take, but a patch is a standing maintenance cost and\n // this is one assignment that stays correct even if upstream fixes it).\n //\n // `sheets: []` rather than `{}` because `(this.model.sheets || [])` is what is\n // read.\n //\n // The double assertion is NOT laziness and a single `as typeof reader.model`\n // does not compile — checked, not assumed:\n //\n // TS2352: Conversion of type '{ sheets: never[]; }' to type 'WorkbookModel'\n // may be a mistake because neither type sufficiently overlaps with the\n // other. … missing the following properties: creator, lastModifiedBy,\n // lastPrinted, created, and 17 more.\n //\n // A deliberately PARTIAL seed is the whole point — exceljs overwrites it with\n // the real model a few entries later, and filling 21 fields with invented\n // values to satisfy the compiler would be worse than the assertion: it would\n // put fabricated workbook metadata one `_parseWorkbook` failure away from\n // being read as real.\n // toolkit-smell-allow: partial seed for a 21-field type; single assertion rejected by TS2352 (see above)\n reader.model = { sheets: [] } as unknown as typeof reader.model\n\n let dataRowNumber = 0\n let sheetIndex = 0\n let matchedTarget = false\n\n // Drive the worksheet iterator BY HAND rather than with `for await`, so the\n // `finally` below can resume it. Abandoning exceljs's `parse()` generator —\n // which `return`/`break` out of a `for await` does — LEAKS a temp file and an\n // fd per deferred worksheet, because `tempFileCleanupCallback()` sits after\n // the `yield*` with no `finally` of its own (`workbook-reader.js:140-149`).\n //\n // Measured, with a negative control: 25 early-returning reads of a two-sheet\n // deferred-order workbook leaked 50 fds and 50 temp files; the draining shape\n // below leaks 0 of each. `tmp`'s graceful-cleanup hook reclaims the files at\n // NORMAL process exit only, which is no help to a long-lived queue worker and\n // no help at all against the fd ceiling: at the #842 audit's 5000-run cap\n // this is an EMFILE, and an EMFILE arrives as the uncaught stream error the\n // `open()` above exists to prevent.\n //\n // THE COST IS PROPORTIONAL TO THE WORKSHEETS AFTER THE TARGET, which is zero\n // for the single-sheet feeds this reader mostly sees and 4.7× on a workbook\n // whose target is sheet 1 and whose sheet 2 holds 40k rows. Advancing the\n // parent iterator WITHOUT consuming each trailing worksheet is far cheaper\n // and does release the temp files — but it strands exceljs's\n // `createReadStream` on each one, i.e. it trades the disk leak for the fd\n // leak, and the fd leak is the one that ends in EMFILE. So: full drain.\n const worksheets = reader[Symbol.asyncIterator]()\n let readerExhausted = false\n try {\n while (true) {\n const next = await worksheets.next()\n if (next.done) {\n readerExhausted = true\n break\n }\n const worksheet = next.value\n sheetIndex += 1\n const isTarget =\n targetSheetIndex === undefined ? sheetIndex === 1 : sheetIndex === targetSheetIndex\n\n if (!isTarget) {\n // Drain a non-target worksheet so the underlying zip stream advances to the\n // next; exceljs's streaming reader stalls if a worksheet is left unconsumed.\n for await (const _skip of worksheet) {\n void _skip\n }\n continue\n }\n matchedTarget = true\n\n let excelRowNum = 0\n let header: string[] | null = null\n for await (const row of worksheet) {\n excelRowNum += 1\n const cells = rowCells(row.values)\n\n if (hasHeader && header === null) {\n // Skip rows before the configured header row, then capture it (trimmed).\n if (excelRowNum < headerRowIndex + 1) continue\n header = cells.map((c) => c.trim())\n // FAIL LOUDLY on an empty header, because the alternative is SILENT DATA\n // CORRUPTION and we have watched it happen.\n //\n // exceljs assigns `this.sharedStrings = []` at the TOP of\n // `_parseSharedStrings`, before parsing a single entry — and an empty\n // array is truthy, which is what the inline-worksheet guard tests. So a\n // shared-strings entry that yields nothing (a truncated or starved read;\n // observed in CI under load) leaves every string cell resolving to\n // `sharedStrings[index]` → `undefined`. The header parses to zero\n // columns, every data row maps to `{}`, and the import reports SUCCESS\n // with every value dropped. Reproduced deterministically by rebuilding a\n // workbook with an empty `sst`: two rows read, no values, no error.\n //\n // A header row with no usable column is never a legitimate import, so\n // there is no case to preserve. Refusing costs an operator a clear error\n // and saves them a silently empty dataset they would have to notice\n // themselves — the same posture the rest of this package takes toward\n // config mistakes.\n if (header.every((c) => c === '')) {\n throw new XlsxEmptyHeaderError(excelRowNum)\n }\n assertUniqueHeaders(header)\n continue\n }\n\n dataRowNumber += 1\n const rec: Record<string, string> = {}\n if (header) {\n for (let i = 0; i < header.length; i += 1) {\n const key = header[i]\n if (key) rec[key] = cells[i] ?? ''\n }\n } else {\n for (let i = 0; i < cells.length; i += 1) rec[String(i)] = cells[i] ?? ''\n }\n yield { rowNumber: dataRowNumber, row: rec }\n }\n\n // Target worksheet fully read. Stop yielding — the `finally` walks the\n // remaining worksheets so exceljs reaches its cleanup.\n break\n }\n } finally {\n // Runs on every exit: target read, an empty-header throw, or the CONSUMER\n // abandoning us mid-stream (a `break` in the caller's `for await` calls\n // `.return()`, which lands here). Whatever the reason, exceljs still owns\n // temp files it only releases as the consumer pulls past each worksheet.\n //\n // The whole body is CONTAINED, for the same reason `handle.close()` below\n // is: a throw from a `finally` REPLACES whatever the try block was\n // propagating. Trailing worksheets are parsed here, so a malformed one\n // would overwrite an `XlsxEmptyHeaderError` with an XML error — turning\n // the #842 audit's `corrupt` verdict into a meaningless `error` and\n // failing an import whose target sheet read perfectly. Measured: two of\n // four malformed-trailing-sheet variants masked the refusal before this\n // catch existed. Cleanup is best-effort by definition; the error the\n // caller came for is not.\n try {\n while (!readerExhausted) {\n const rest = await worksheets.next()\n if (rest.done) break\n for await (const _skip of rest.value) {\n void _skip\n }\n }\n } catch {\n // Deliberately swallowed — see above. A trailing worksheet we only read\n // to satisfy exceljs's cleanup contract cannot be allowed to speak for\n // the read the caller actually asked for. Worst case we leak the temp\n // files this drain existed to release, which is what `main` did on\n // EVERY read.\n }\n }\n\n // Reached only when a specific sheet was requested but never matched — the\n // first-sheet default always matches on a non-empty workbook.\n if (targetSheetIndex !== undefined && !matchedTarget) {\n throw new Error(`xlsx worksheet ${targetSheetIndex} not found`)\n }\n } finally {\n // Order matters: destroy the reader of the fd before closing the fd.\n source?.destroy()\n await handle.close().catch(() => {\n // Swallowed deliberately, and biome's `noUnsafeFinally` agrees: a throw\n // here would REPLACE whatever the try block was propagating, and what it\n // propagates is the point of this reader — an empty-header refusal has to\n // reach the caller intact. Closing a read-only fd we are finished with can\n // fail only in ways that are not actionable, and the fd is reclaimed at\n // process exit regardless.\n })\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/imports",
3
- "version": "0.83.0",
3
+ "version": "0.84.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.83.0",
57
- "@murumets-ee/db": "0.83.0",
58
- "@murumets-ee/entity": "0.83.0",
59
- "@murumets-ee/logging": "0.83.0",
60
- "@murumets-ee/queue": "0.83.0",
61
- "@murumets-ee/search-elasticsearch": "0.83.0",
62
- "@murumets-ee/storage": "0.83.0"
56
+ "@murumets-ee/core": "0.84.0",
57
+ "@murumets-ee/logging": "0.84.0",
58
+ "@murumets-ee/db": "0.84.0",
59
+ "@murumets-ee/entity": "0.84.0",
60
+ "@murumets-ee/queue": "0.84.0",
61
+ "@murumets-ee/search-elasticsearch": "0.84.0",
62
+ "@murumets-ee/storage": "0.84.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/node": "^20.19.42",
@@ -1,2 +0,0 @@
1
- import{i as e}from"./read-feed-DGjqejw3.mjs";import{open as t}from"node:fs/promises";var n=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}},r=class extends Error{constructor(e){super(`xlsx path "${e}" is not a regular file`),this.name=`XlsxNotAFileError`}};function i(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 a(e){let t=[];if(Array.isArray(e))for(let n=1;n<e.length;n+=1)t[n-1]=i(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]=i(r))}for(let e=0;e<t.length;e+=1)t[e]===void 0&&(t[e]=``);return t}async function*o(i){let{filePath:o,hasHeader:s=!0,headerRowIndex:c=0,sheet:l}=i,u=await import(`exceljs`),d;if(l!==void 0&&(d=Number.parseInt(l,10),Number.isNaN(d)||d<1))throw Error(`Invalid xlsx sheet "${l}" — expected a 1-based worksheet index`);let f=await t(o,`r`),p;try{if(!(await f.stat()).isFile())throw new r(o);p=f.createReadStream({autoClose:!1});let t=new u.stream.xlsx.WorkbookReader(p,{worksheets:`emit`,sharedStrings:`cache`,styles:`ignore`,hyperlinks:`ignore`,entries:`ignore`});t.model={sheets:[]};let i=0,l=0,m=!1,h=t[Symbol.asyncIterator](),g=!1;try{for(;;){let t=await h.next();if(t.done){g=!0;break}let r=t.value;if(l+=1,!(d===void 0?l===1:l===d)){for await(let e of r);continue}m=!0;let o=0,u=null;for await(let t of r){o+=1;let r=a(t.values);if(s&&u===null){if(o<c+1)continue;if(u=r.map(e=>e.trim()),u.every(e=>e===``))throw new n(o);e(u);continue}i+=1;let l={};if(u)for(let e=0;e<u.length;e+=1){let t=u[e];t&&(l[t]=r[e]??``)}else for(let e=0;e<r.length;e+=1)l[String(e)]=r[e]??``;yield{rowNumber:i,row:l}}break}}finally{try{for(;!g;){let e=await h.next();if(e.done)break;for await(let t of e.value);}}catch{}}if(d!==void 0&&!m)throw Error(`xlsx worksheet ${d} not found`)}finally{p?.destroy(),await f.close().catch(()=>{})}}export{n as XlsxEmptyHeaderError,r as XlsxNotAFileError,o as streamXlsx};
2
- //# sourceMappingURL=xlsx-reader-_ioPAqm5.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"xlsx-reader-_ioPAqm5.mjs","names":[],"sources":["../src/xlsx-reader.ts"],"sourcesContent":["/**\n * Streaming XLSX reader (commerce-demo phase 02, D005) — the exceljs branch of\n * the feed reader. Yields the SAME `AsyncIterable<{ rowNumber, row }>` contract as\n * the csv-parse `streamFeed`, so the runner / transform layer downstream is\n * format-agnostic.\n *\n * **exceljs over SheetJS** (R02): we parse operator-uploaded spreadsheets — the\n * exact threat model of the unfixed npm `xlsx` prototype-pollution CVE\n * (CVE-2023-30533). exceljs is MIT, ships its own types, has a real streaming\n * `WorkbookReader`, and carries no equivalent advisory.\n *\n * **exceljs is `await import()`-ed**, never statically imported, so it stays out\n * of this package's static module graph — heavy + Node-only, it must not be\n * inlinable into any `*-ui` server-pages bundle (CLAUDE.md bundle budgets). The\n * `read-feed` dispatcher additionally only loads THIS module for `format: 'xlsx'`.\n */\n\nimport { open } from 'node:fs/promises'\nimport type { CellValue } from 'exceljs'\nimport { assertUniqueHeaders, type StreamFeedRow } from './streaming.js'\n\n/**\n * Thrown by {@link streamXlsx} when the configured header row parses to zero\n * readable columns — the signature of the silent-corruption defect documented\n * on the guard below.\n *\n * A distinct CLASS rather than a bare `Error` because the retroactive audit\n * (`auditEmptyHeaderRuns`, #842) has to tell \"this file's header is\n * unreadable\" apart from every other way reading a spreadsheet can fail\n * (missing object, unreadable zip, wrong sheet index). Matching on the message\n * would make that classification a regex over prose that anyone is free to\n * reword — which is exactly the kind of detector that silently stops detecting.\n * The message text is unchanged from the original throw.\n */\nexport class XlsxEmptyHeaderError extends Error {\n /** 1-based worksheet row the empty header was read from. */\n readonly headerRowNumber: number\n\n constructor(headerRowNumber: number) {\n super(\n `xlsx header row ${headerRowNumber} has no readable columns — the file may be corrupt, ` +\n 'or its shared strings failed to load. Refusing rather than importing empty rows.',\n )\n this.name = 'XlsxEmptyHeaderError'\n this.headerRowNumber = headerRowNumber\n }\n}\n\n/**\n * Thrown by {@link streamXlsx} when the path resolves to something that is not a\n * regular file — a directory, most often. `open()` SUCCEEDS on a directory on\n * Linux (POSIX allows `O_RDONLY` on one) and only the read fails, so this is a\n * check the reader has to make itself.\n *\n * Typed for the same reason {@link XlsxEmptyHeaderError} is: the #842 audit has\n * to tell \"the evidence is unreadable\" from \"the evidence is readable and bad\",\n * and matching prose is how a classifier quietly stops classifying.\n */\nexport class XlsxNotAFileError extends Error {\n constructor(filePath: string) {\n super(`xlsx path \"${filePath}\" is not a regular file`)\n this.name = 'XlsxNotAFileError'\n }\n}\n\nexport interface XlsxReadOptions {\n /** Path to the .xlsx file on local disk. */\n filePath: string\n /** First row is the header (column names). `false` → positional `{ \"0\": … }` keys. Default `true`. */\n hasHeader?: boolean\n /** 0-based index of the header row (rows before it are skipped). Default `0`. */\n headerRowIndex?: number\n /** Worksheet to read, as a 1-based index string. Omit → the first worksheet. */\n sheet?: string\n}\n\n/** Convert one exceljs cell value to a plain string (feeds are text/number cells). */\nfunction cellToString(v: CellValue): string {\n if (v === null || v === undefined) return ''\n if (typeof v === 'string') return v\n if (typeof v === 'number' || typeof v === 'boolean') return String(v)\n if (v instanceof Date) return v.toISOString()\n // Object cell values: rich text, hyperlink, formula result, error.\n if ('richText' in v && Array.isArray(v.richText)) {\n return v.richText.map((r) => r.text).join('')\n }\n if ('text' in v && typeof v.text === 'string') return v.text\n if ('result' in v && v.result !== undefined && v.result !== null) {\n const r = v.result\n if (r instanceof Date) return r.toISOString()\n // A nested error object (formula error) surfaces as empty; primitives stringify.\n return typeof r === 'object' ? '' : String(r)\n }\n return ''\n}\n\n/** Normalise an exceljs `row.values` (1-indexed array OR object form) to a 0-indexed string[]. */\nfunction rowCells(values: CellValue[] | { [key: string]: CellValue }): string[] {\n const out: string[] = []\n if (Array.isArray(values)) {\n // exceljs row arrays are 1-indexed (`values[0]` is empty); shift to 0-based.\n for (let i = 1; i < values.length; i += 1) out[i - 1] = cellToString(values[i])\n } else {\n for (const [k, v] of Object.entries(values)) {\n const idx = Number.parseInt(k, 10)\n if (!Number.isNaN(idx) && idx >= 1) out[idx - 1] = cellToString(v)\n }\n }\n // Fill any holes (sparse rows) with empty strings — the contract is \"missing\n // cell = ''\", matching streamFeed.\n for (let i = 0; i < out.length; i += 1) if (out[i] === undefined) out[i] = ''\n return out\n}\n\n/**\n * Async-iterable over the data rows of an .xlsx file. `rowNumber` is the 1-based\n * DATA row index (header excluded). Cell values are coerced to strings so the\n * mapping engine's per-column transforms (which expect raw string cells) apply\n * uniformly across csv/tsv/xlsx.\n */\nexport async function* streamXlsx(options: XlsxReadOptions): AsyncIterable<StreamFeedRow> {\n const { filePath, hasHeader = true, headerRowIndex = 0, sheet } = options\n const ExcelJS = await import('exceljs')\n\n // `WorksheetReader` doesn't expose its sheet id, so count emitted worksheets\n // (1-based) to match the configured `sheet`. Default: the first worksheet.\n // A provided-but-invalid sheet FAILS FAST rather than silently reading sheet 1\n // — and before we touch the filesystem, so a config typo costs no I/O.\n let targetSheetIndex: number | undefined\n if (sheet !== undefined) {\n targetSheetIndex = Number.parseInt(sheet, 10)\n if (Number.isNaN(targetSheetIndex) || targetSheetIndex < 1) {\n throw new Error(`Invalid xlsx sheet \"${sheet}\" — expected a 1-based worksheet index`)\n }\n }\n\n // OPEN THE FILE OURSELVES rather than handing exceljs a path, because a path\n // makes every filesystem failure an UNCAUGHT EXCEPTION that kills the process\n // instead of a rejection the caller can handle.\n //\n // `_getStream` does `fs.createReadStream(input)` for a string input and then\n // `stream.pipe(zip)` (`workbook-reader.js:38,81`). `pipe()` does not forward\n // SOURCE errors, and exceljs registers no `error` listener on that stream — so\n // an ENOENT / EACCES / EISDIR surfaces as `Unhandled 'error' event`. Verified\n // by execution, not inference: all three crash the process outright. In the\n // queue worker that kills the job mid-flight instead of routing through\n // `markFailed`; in the #842 audit one unreadable file discards the whole sweep.\n //\n // An fd fixes it at the root rather than patching the symptom:\n // - `open()` rejects normally on ENOENT and EACCES.\n // - a DIRECTORY still opens on Linux (checked — O_RDONLY on a dir succeeds\n // and only the read gives EISDIR), so the `isFile()` check below is\n // load-bearing. It runs against the fd, so it cannot be raced.\n // - once the fd is held, the inode survives `unlink` (checked: the read\n // still returns the bytes). That closes the window against the\n // `@every 1h` retention sweep, which deletes exactly these files and which\n // the audit is documented as racing.\n const handle = await open(filePath, 'r')\n // Held so the outer `finally` can destroy it BEFORE the fd goes away. With\n // `autoClose: false` nothing tears this stream down except reaching the end,\n // and the drain below deliberately swallows errors — so a malformed trailing\n // worksheet can leave the source mid-read while `handle.close()` invalidates\n // the descriptor underneath it. exceljs registers no `error` listener on the\n // stream it pipes (the whole reason this function opens the file itself), so\n // that EBADF would arrive as an uncaught event: the exact failure mode the fd\n // was introduced to eliminate, re-entering through the back door.\n let source: ReturnType<typeof handle.createReadStream> | undefined\n try {\n const stats = await handle.stat()\n if (!stats.isFile()) {\n throw new XlsxNotAFileError(filePath)\n }\n\n source = handle.createReadStream({ autoClose: false })\n const reader = new ExcelJS.stream.xlsx.WorkbookReader(source, {\n worksheets: 'emit',\n sharedStrings: 'cache',\n styles: 'ignore',\n hyperlinks: 'ignore',\n entries: 'ignore',\n })\n\n // Seed `model` so exceljs cannot dereference it before it has parsed\n // `xl/workbook.xml` — an UPSTREAM defect, and the cause of the xlsx-reader\n // \"flake\" that failed CI intermittently for months (#619, #835).\n //\n // Its streaming reader parses a worksheet INLINE, rather than deferring it to\n // a temp file, when `this.sharedStrings && this.workbookRels` are both set as\n // the entry arrives (`workbook-reader.js:109`). Neither implies\n // `xl/workbook.xml` has been read. The inline path then does\n // `matchingRel && (this.model.sheets || []).find(…)` (`:303`) — guarding\n // `this.workbookRels` on the line above and `this.model.sheets` not at all —\n // so any workbook whose zip orders `sharedStrings.xml` before the worksheet,\n // with `workbook.xml` after it, throws `Cannot read properties of undefined\n // (reading 'sheets')`.\n //\n // That is a PRODUCTION hazard, not a test artifact: this function reads\n // operator-uploaded spreadsheets and the writer chooses the entry order. It\n // presented as flakiness only because the fixtures our own tests write happen\n // to sit on the safe side of the condition.\n //\n // An empty `sheets` makes the lookup miss, so the worksheet keeps its default\n // id/name/state until `_parseWorkbook` overwrites `model` with the real one a\n // few entries later — which is exactly what the deferred path already does.\n // Preferred over a `pnpm patch` (exceljs 4.4.0 is the latest release, so there\n // is no upstream fix to take, but a patch is a standing maintenance cost and\n // this is one assignment that stays correct even if upstream fixes it).\n //\n // `sheets: []` rather than `{}` because `(this.model.sheets || [])` is what is\n // read.\n //\n // The double assertion is NOT laziness and a single `as typeof reader.model`\n // does not compile — checked, not assumed:\n //\n // TS2352: Conversion of type '{ sheets: never[]; }' to type 'WorkbookModel'\n // may be a mistake because neither type sufficiently overlaps with the\n // other. … missing the following properties: creator, lastModifiedBy,\n // lastPrinted, created, and 17 more.\n //\n // A deliberately PARTIAL seed is the whole point — exceljs overwrites it with\n // the real model a few entries later, and filling 21 fields with invented\n // values to satisfy the compiler would be worse than the assertion: it would\n // put fabricated workbook metadata one `_parseWorkbook` failure away from\n // being read as real.\n // toolkit-smell-allow: partial seed for a 21-field type; single assertion rejected by TS2352 (see above)\n reader.model = { sheets: [] } as unknown as typeof reader.model\n\n let dataRowNumber = 0\n let sheetIndex = 0\n let matchedTarget = false\n\n // Drive the worksheet iterator BY HAND rather than with `for await`, so the\n // `finally` below can resume it. Abandoning exceljs's `parse()` generator —\n // which `return`/`break` out of a `for await` does — LEAKS a temp file and an\n // fd per deferred worksheet, because `tempFileCleanupCallback()` sits after\n // the `yield*` with no `finally` of its own (`workbook-reader.js:140-149`).\n //\n // Measured, with a negative control: 25 early-returning reads of a two-sheet\n // deferred-order workbook leaked 50 fds and 50 temp files; the draining shape\n // below leaks 0 of each. `tmp`'s graceful-cleanup hook reclaims the files at\n // NORMAL process exit only, which is no help to a long-lived queue worker and\n // no help at all against the fd ceiling: at the #842 audit's 5000-run cap\n // this is an EMFILE, and an EMFILE arrives as the uncaught stream error the\n // `open()` above exists to prevent.\n //\n // THE COST IS PROPORTIONAL TO THE WORKSHEETS AFTER THE TARGET, which is zero\n // for the single-sheet feeds this reader mostly sees and 4.7× on a workbook\n // whose target is sheet 1 and whose sheet 2 holds 40k rows. Advancing the\n // parent iterator WITHOUT consuming each trailing worksheet is far cheaper\n // and does release the temp files — but it strands exceljs's\n // `createReadStream` on each one, i.e. it trades the disk leak for the fd\n // leak, and the fd leak is the one that ends in EMFILE. So: full drain.\n const worksheets = reader[Symbol.asyncIterator]()\n let readerExhausted = false\n try {\n while (true) {\n const next = await worksheets.next()\n if (next.done) {\n readerExhausted = true\n break\n }\n const worksheet = next.value\n sheetIndex += 1\n const isTarget =\n targetSheetIndex === undefined ? sheetIndex === 1 : sheetIndex === targetSheetIndex\n\n if (!isTarget) {\n // Drain a non-target worksheet so the underlying zip stream advances to the\n // next; exceljs's streaming reader stalls if a worksheet is left unconsumed.\n for await (const _skip of worksheet) {\n void _skip\n }\n continue\n }\n matchedTarget = true\n\n let excelRowNum = 0\n let header: string[] | null = null\n for await (const row of worksheet) {\n excelRowNum += 1\n const cells = rowCells(row.values)\n\n if (hasHeader && header === null) {\n // Skip rows before the configured header row, then capture it (trimmed).\n if (excelRowNum < headerRowIndex + 1) continue\n header = cells.map((c) => c.trim())\n // FAIL LOUDLY on an empty header, because the alternative is SILENT DATA\n // CORRUPTION and we have watched it happen.\n //\n // exceljs assigns `this.sharedStrings = []` at the TOP of\n // `_parseSharedStrings`, before parsing a single entry — and an empty\n // array is truthy, which is what the inline-worksheet guard tests. So a\n // shared-strings entry that yields nothing (a truncated or starved read;\n // observed in CI under load) leaves every string cell resolving to\n // `sharedStrings[index]` → `undefined`. The header parses to zero\n // columns, every data row maps to `{}`, and the import reports SUCCESS\n // with every value dropped. Reproduced deterministically by rebuilding a\n // workbook with an empty `sst`: two rows read, no values, no error.\n //\n // A header row with no usable column is never a legitimate import, so\n // there is no case to preserve. Refusing costs an operator a clear error\n // and saves them a silently empty dataset they would have to notice\n // themselves — the same posture the rest of this package takes toward\n // config mistakes.\n if (header.every((c) => c === '')) {\n throw new XlsxEmptyHeaderError(excelRowNum)\n }\n assertUniqueHeaders(header)\n continue\n }\n\n dataRowNumber += 1\n const rec: Record<string, string> = {}\n if (header) {\n for (let i = 0; i < header.length; i += 1) {\n const key = header[i]\n if (key) rec[key] = cells[i] ?? ''\n }\n } else {\n for (let i = 0; i < cells.length; i += 1) rec[String(i)] = cells[i] ?? ''\n }\n yield { rowNumber: dataRowNumber, row: rec }\n }\n\n // Target worksheet fully read. Stop yielding — the `finally` walks the\n // remaining worksheets so exceljs reaches its cleanup.\n break\n }\n } finally {\n // Runs on every exit: target read, an empty-header throw, or the CONSUMER\n // abandoning us mid-stream (a `break` in the caller's `for await` calls\n // `.return()`, which lands here). Whatever the reason, exceljs still owns\n // temp files it only releases as the consumer pulls past each worksheet.\n //\n // The whole body is CONTAINED, for the same reason `handle.close()` below\n // is: a throw from a `finally` REPLACES whatever the try block was\n // propagating. Trailing worksheets are parsed here, so a malformed one\n // would overwrite an `XlsxEmptyHeaderError` with an XML error — turning\n // the #842 audit's `corrupt` verdict into a meaningless `error` and\n // failing an import whose target sheet read perfectly. Measured: two of\n // four malformed-trailing-sheet variants masked the refusal before this\n // catch existed. Cleanup is best-effort by definition; the error the\n // caller came for is not.\n try {\n while (!readerExhausted) {\n const rest = await worksheets.next()\n if (rest.done) break\n for await (const _skip of rest.value) {\n void _skip\n }\n }\n } catch {\n // Deliberately swallowed — see above. A trailing worksheet we only read\n // to satisfy exceljs's cleanup contract cannot be allowed to speak for\n // the read the caller actually asked for. Worst case we leak the temp\n // files this drain existed to release, which is what `main` did on\n // EVERY read.\n }\n }\n\n // Reached only when a specific sheet was requested but never matched — the\n // first-sheet default always matches on a non-empty workbook.\n if (targetSheetIndex !== undefined && !matchedTarget) {\n throw new Error(`xlsx worksheet ${targetSheetIndex} not found`)\n }\n } finally {\n // Order matters: destroy the reader of the fd before closing the fd.\n source?.destroy()\n await handle.close().catch(() => {\n // Swallowed deliberately, and biome's `noUnsafeFinally` agrees: a throw\n // here would REPLACE whatever the try block was propagating, and what it\n // propagates is the point of this reader — an empty-header refusal has to\n // reach the caller intact. Closing a read-only fd we are finished with can\n // fail only in ways that are not actionable, and the fd is reclaimed at\n // process exit regardless.\n })\n }\n}\n"],"mappings":"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"}